“People don't rise from nothing. We do owe something to parentage and patronage.”
Outiliers, Malcolm Gladwell
Sistemas operacionais (SO) são programas desenvolvidos para atuarem como intermediários entre o usuário e o hardware. É uma camada complexa de incorporando estruturas de baixo e alto níveis. A maioria dos SOs atuais tem interface gráfica e diversos aplicativos para facilitar tarefas recorrentes, além de tecnologias para facilitar a instalação e comunicação com novos dispositivos (plug and play).
Um SO tem dois objetivos básicos: abstrair e gerenciar. A abstração permite que atividades complexas sejam encapsuladas em interfaces disponíveis aos desenvolvedores para que não precisem começar sempre do zero a comunicação com o hardware. No caso de gestão de recursos, o SO administra os acessos ao processador, memória e discos, além da comunicação com outros dispositivos, seus controladores e drivers.
Podemos dividir os SOs em várias categorias: batch (em lote), rede, distribuído, multiusuário, servidor, desktop, móvel, embarcado e tempo real.
Os elementos básicos de um SO são:
O kernel é a parte mais baixa do SO e tem um modo privilegiado de acesso ao processador.
SOs podem ser divididos em diferentes arquiteturas:
São exemplos: máquinas virtuais, contêineres, sistemas exonúcleo e uninúcleo.
Dadas as operações básicas que o SO e os programas do usuários podem executar em um dado momento, o núcleo precisa definir como vai acionar o processador para cada pedido de execução. Uma visão ingênua do problema seria executar uma fila de programas de forma sequencial, onde o primeiro é executado por inteiro, depois o segundo e assim por diante. O problema desse tipo de abordagem é o tempo de espera para os outros itens da lista. Se o primeiro programa for muito grande ou lento (muitas operações) todos os outros ficarão aguardando. O mesmo aconteceria se o processador tivesse que aguardar por operações em outros dispositivos como disco, rede, etc… Uma solução prática utilizada com sucesso foi a divisão do tempo do processador em várias tarefas, o que dá a ilusão de multitarefas.
Um processador só pode executar uma instrução por vez. Para isso, definimos o conceito de tarefa, ou seja, um fluxo de instruções que vai resolver alguma finalidade. A tarefa é uma execução sequencial e dinâmica, diferente de um programa, que é estático e não interage com outras entidades. As tarefas podem ser implementadas como processos (threads). Uma tarefa tem várias propriedades como: comportamento, duração e prioridade. O sistema operacional deve decidir a ordem de execução e a organização de todas as tarefas pendentes.
Os sistemas mais antigos eram monotarefas, ou seja, só executavam uma de cada vez. Cada usuário deveria esperar a tarefa de outro ser executada completamente antes de poder colocar a sua para processamento. Como as tarefas foram ficando mais complexas, um operador humano que alternava entre diferentes atividades foi substituído por um programa monitor carregado em memória para gerenciar e executar os programas.
Os sistemas multitarefas aumentam a eficiência do processador, interrompendo uma tarefa mais lenta para executar alguma outra da fila de espera. Esse passo necessitou de um programa monitor mais sofisticado e também mais memória.
Nos sistemas de tempo compartilhado, cada tarefa tem uma fatia de tempo do processador, onde uma gestão de quem está fila organiza as atividades enviadas ao processador. Há um temporizador no hardware para controlar o recurso de preempção (remover uma tarefa à força).
Uma tarefa passa por vários estágios:
O estado de uma tarefa em um instante específico do tempo é chamado de contexto. Ela é representada no núcleo por uma estrutura de dados chamada descritor, do tipo TCB (Task Control Block) ou PCB (Process Control Block). Os descritores são normalmente armazenados em listas ou vetores. Quando o SO muda de uma tarefa para outra, deve salvar o estado da tarefa atual no TCB e suspendê-la, atividade chamada troca de contexto. As rotinas relacionadas com a gestão de tarefas são feitas pelo dispatcher e a escolha da próxima tarefa pelo scheduler.
Uma medida de eficiência do uso do processo é dada pela equação $\epsilon =\frac{t_q}{t_q+t_{tc}}$, onde $t_q$ é o tempo de quantum (que a tarefa tem para usar o processador) e $t_{tc}$ o tempo de troca de contexto.
O conceito de processo vem historicamente da junção da tarefa e seus recursos em dado momento, seria uma casca isolada de execução. Porém, atualmente os SOs podem ter mais de uma tarefa por processo, que viraram uma unidade de contexto, um contêiner de utilização (área de memória, arquivos abertos, conexões, etc…). Processos contém tarefas que usam esses recursos de forma compartilhada e isolada pelo hardware. Por padrão um SO moderno associa uma tarefa por recurso.
Para criar e destruir tarefas o SO precisa disponibilizar essas operações por chamadas do sistema. No Linux, por exemplo, o comando fork copia um processo para outro, onde os recursos do núcleo são os mesmos, mas executam em áreas da memória distintas.
É importante que o desenvolver destrua o processo para liberar os recursos associados. Tarefas dentro do mesmo processo podem trocar informações entre si. Tarefas entre processos diferentes devem se comunicar pelo IPC (Inter-Process Communication).
No sistemas UNIX os processos são organizados em árvore, o que facilita a comunicação quando o processo pai é encerrado. Isso não é igual no Windows, que não tem diferença entre processos pai e filho.
Uma thread (processo leve) é um fluxo de execução independente dentro do mesmo processo, ou seja, um processo contém uma ou mais threads, que compartilham recursos, mas executando códigos distintos. Seu contexto local é chamado de TLS (Thread Local Storage). Threads também são usadas no núcleo, para executar, por exemplo, rotinas de drivers. Modelos de threads:
O padrão de programação de threads vem do IEEE POSIX 1003.1.c ou POSIX Threads.
O escalador escolha qual tarefa será executada em um determinado momento, para isso deve priorizar esse processo de acordo com o tipo de tarefa (tempo real, interativas ou lote) e seu comportamento (orientadas a processamento e/ou orientadas para entrada e saída). Os critérios mais comuns utilizados são: (tempo de execução, tempo de espera, tempo de resposta, justiça e eficiência).
Os tipos de sistema operacional são: preemptivo e cooperativos. No primeiro, a tarefa é suspensa por algum evento e volta para a fila de processamento. No caso cooperativa, a tarefa só é removida se está aguardando algum recurso. Algoritmos de escalonamento:
A comunicação entre processos e tarefas é importante para que haja coordenação entre as atividades com um mesmo fim. Nem sempre um programa sequencial resolve o problema da melhor maneira, principalmente do ponto de vista da eficiência. As tarefas devem ter uma forma se comunicar para que a coordenação ocorra.
A comunicação pode ser direta ou indireta. No primeiro caso o emissor identifica de forma clara o receptor e vice-versa. No último, não há essa necessidade. Existe um canal de comunicação para interação entre as partes. Quanto ao sincronismo, a comunicação pode ser síncrona, ou bloqueante, assíncrona ou não bloqueante (utilizando um buffer para armazenar os dados) e semissíncrona, que bloqueia apenas por um prazo definido. A forma de envio é sequência de mensagens independentes (pacotes de dados) ou fluxo sequencial contínuo. A capacidade dos canais pode ser nula (não armazena dados), infinta (pode sempre enviar dados) e finita.
Problemas na comunicação:
Também podemos classificar a comunicação pelo número de participantes:
Sobre os mecanismos de comunicação:
Sempre que algum recurso é compartilhado há problemas de coordenação de acesso que precisam ser resolvidos. Perguntas do tipo: o que acontece se diferentes threads acessam a mesma variável no mesmo momento? quais problemas de lógica acontecem quando fluxos modificam os recursos em sequencias não previstas?
Para resolver estes e outros problemas foram criados algoritmos de sincronia e concorrência. Os casos mais comuns são resumidos abaixo:
Condições de disputa (race conditions): Ocorrem quando há interferência de dois fluxos distintos e concorrentes em recursos compartilhados. Isso pode causar diferenças em variáveis pois cada tarefa, em um determinado ponto, alterou um valor sem que outra tarefa fosse notificada. É um tipo de erro dinâmico, que acontece em tempo de execução.
Condições de Bernstein: Define formalmente quando tarefas paralelas não têm risco de condição de disputa. Verifica quando uma variável pode ser escrita simultaneamente por duas ou mais tarefas.
Antes de definir os mecanismos para as possíveis soluções deste problema, denominamos “seção crítica” o trecho de código que manipula variáveis que são compartilhadas entre tarefas e podem sofre de condições de corrida. Para evitar o acesso concorrente, utilizamos a “exclusão mútua”, que permite que somente uma tarefa por vez acesse determinado recurso. Alguns critérios devem ser respeitados:
Tentativas de solução do problema de acesso à seção crítica:
Dois algoritmos foram propostos para resolver o problema: Dekker e Peterson.
Operações atômicas: permite isolar uma variável dos contextos, ou seja, somente uma tarefa tem acesso para atribuir e testar os valores em um instante de tempo t.
TSL (Test-and-Set Lock): instrução de máquina troca valor de uma variável e retornar o valor antigo. Atualmente são denominadas RMW (Read-Modify-Write), CAS (Compare-And-Swap), XCHG (Exchange);
Semáforo: São utilizados para coordenar de maneira eficiente e flexível o controle de exclusão mútua entre n tarefas. É utilizado para desenvolver mecanismos mais complexos como monitores. Possui uma fila de tarefas e um contador, porém, o acesso é somente via operações atômicas:
Exemplo:
init(s,1); // inicializa o semáforo void alterarConta(semaphore s, int *saldo, int valor) { down(s); // solicitação de acesso (*saldo) += valor; // seção crítica up(s); // libera o acesso }
Neste caso resolvemos os problemas de eficiência, justiça e independência.
Semáforos simplificados são chamados de mutexes (mutual exclusion), semáforos binários ou locks, pois só assumem dois valores possíveis: livre (1) e ocupado (0).
Variáveis de condição: são usadas para avaliar que uma determinada condição é verdade e assim acordar uma tarefa da fila para execução. Deve ser usada com um mutex para garantir exclusão mútua.
Semântica de Hoare: Acontece quando a variação de condição perde o mutex e a operação de sinalizar que o recurso está livre é entregue à primeira tarefa da fila;
Semântica Mesa: A operação de sinalizar que o recurso está livre acorda uma tarefa que espera pela condição, sem suspender a tarefa atual.
Monitores: em programas complexos o programador deve determinar as condições de sincronização das tarefas, o que pode causar problemas de exclusão mútua. Hansen e Hoare definiram o conceito de monitor, que sincroniza a requisição e liberação de uma seção crítica de forma transparente. Utiliza um mutex para controle de exclusão mútua. Ele encapsula o recurso compartilhado na forma de um objeto e provê modos de acesso para manipulá-lo.
As memórias são divididas entre dois tipos principais: voláteis e não-voláteis. Essa característica nos diz se os dados armazenados são perdidos ou não caso o computador seja desligado. As memórias do processador (registradores e cache L1) são voláteis, assim como o cache da placa mãe (L2) e a memória RAM. Já as memórias flash, discos rígidos ópticos e fitas magnéticas, não são.
As memórias mais próximas ao processador são mais rápidas, ou seja, registrados, cache L1 e L2. Isso tanto em tempo de acesso como taxa de transferência de dados.
A memória principal ou RAM (Random Access Memory) organiza o uso de armazenamento para o sistema operacional e todos os processos sendo executados em dado momento. Há uma parte da RAM que fica inacessível, sendo utilizada para BIOS, VGA, Video e outras utilidades. O espaço da memória é dividido em bytes, cada um com seu endereço de acesso.
A CPU acessa a memória via o barramento de dados, de endereços e de controle. A quantidade de memória é limitada pelo barramento de dados, sendo que n vias conseguem gerar $2^n$ endereços diferentes. Processadores atuais podem ter até 48 vias para endereços, ou até 256 Tb de memória física.
Os endereços podem ser físicos ou lógicos (virtuais). Os endereços lógicos ficam disponíveis para os processos, que depois são traduzidos em endereços físicos na memória RAM, isso é feito pela MMU (Memory Management Unit), que pode estar integrada à própria CPU. Sua configuração é feita pelo sistema operacional. A MMU também pode controlar os acessos dos processos a diferentes área da memória, um tipo de permissionamento via hardware.
A memória virtual pode ser organizada em:
Partições: o espaço é dividido em partes e cada parte fica com um processo. As divisões podem ter tamanhos iguais ou não. A tabela de partições fica na memória RAM. É um modelo mais simples de organização, mas sofre de flexibilidade e fragmentação.
Segmentos: estruturas semelhantes de cada processo são mapeadas em áreas separadas na memória física. É subdividida em segmento e offset (posição dentro do segmento). É gerenciada pela MMU através da STBR (Segment Table Base Register) e STLR (Segment Table Limit Register). Não é um modelo utilizado atualmente e pode ser muito lento se não implementado com os devidos registradores de segmentos para acelerar o acesso aos dados do processo ativo.
Páginas: divide-se o endereço lógico em bloco de mesmo tamanho, chamados de páginas. Em arquiteturas modernas cada bloco tem 4096 bytes (4 KBytes). No caso da memória física, ela também é divida pelo mesmo tamanho, com cada bloco chamado de frame (quadro). As páginas são mapeadas para quadros através de tabelas de páginas. Há bits de status e controle (flags) relativos às páginas para validação de estado, permissão de leitura e escrita, se usuário pode acessar ou somente o núcleo do sistema, se dados estão na RAM ou em disco, se foi acessada recentemente e se foi modificada. Tabelas multiníveis são utilizadas para relacionar páginas em uma estrutura de dados de árvore, evitando desperdício de espaço linear quando muitas páginas não são utilizadas por um processo. Caches internos das páginas são gerenciados pela MMU e podem acelerar razoavelmente a busca por um objeto em memória.
Alguns processadores podem fazer uso de uma combinação de segmentos e páginas, oferecendo mais flexibilidade ao sistema operacional e aos desenvolvedores.
Um processo que concentra seus acessos em poucas áreas da memória é mais eficiente e essa propriedade é chamada de localidade de referências. É dividida em três formas: temporal, espacial e sequencial. Os fatores importantes na implementação dessa propriedade são: estruturas de dados e algoritmos do programa e qualidade do compilador.
A memória de cada processo pode ser subdividida em espaço do usuário e espaço do Kernel. As áreas devem ser isoladas dos demais processos e contém toda a informação necessária a sua execução. As principais seções são:
As áreas Heap e Stack ficam opostas entre si na memória do processo e tem um espaço livre entre elas, para que possam crescer.
A alocação de variáveis em um programa pode ser de três tipos:
Estática: espaço definido na compilação do programa, reservado no momento da execução do processo até o seu encerramento. Ficam na seção DATA se iniciadas no código-fonte ou na BSS caso contrário.
Automática: variáveis definidas dentro do escopo de uma função são alocadas de forma automática dentro da seção STACK.
Dinâmica: A alocação dos blocos de memória é requisitada pela aplicação, que deve liberá-los depois. Usa a seção HEAP.
A atribuição de endereços de memória para as variáveis do programa pode ser feita na edição (por ex, programando Assembly), na compilação, na ligação (através do arquivo objeto e sua tabela de símbolos), na carga (DLL) ou na execução (com auxílio do hardware). A maioria dos SOs usa uma combinação dos itens anteriores para atribuição.
O mecanismo responsável pela alocação da memória é chamado de alocador e seu papel é reservar espaço na memória RAM de acordo com o fluxo (processos ou sistema operacional). Podem existir em diversos contextos:
O trabalho do alocador pode gerar fragmentação na memória, conforme libera ou aloca dados em estruturas sequenciais. Algumas estratégias de alocação são usadas para reduzir a fragmentação na memória, como first-fit, best-fit, worst-fit e next-fit, sendo as mais eficientes a best-fit e first-fit. A desfragmentação também uma opção, ou seja, reorganizar as áreas de memória e atualizar os endereços físicos das novas posições. A fragmentação interna lida com o problema de arredondar as alocações para evitar espaços livres pequenos entre áreas ocupadas na memória. Estratégias mais sofisticadas:
Memory pool é um tipo de técnica utilizada para pré-alocação de memória onde a aplicação pode obter e liberar registro mais rapidamente.
Como a memória RAM é um recurso escasso e caro, algumas estratégias e técnicas são utilizadas para escrever e ler os dados em outros dispositivos, como por exemplo, discos, são extensões da memória e precisamos levar em conta a eficiência dessas rotinas. Primeiramente, partes ociosas da memória podem ser levadas para outro lugar, sendo que são trazidos de volta quando o processo é reativado. O sistema operacional regula esse processo de transferência. Técnicas usuais:
Existem alguns algoritmos clássicos para escolha das páginas e a substituição na memória:
Alguns algoritmos de página podem levar a mais faltas de páginas, mesmo com mais memória física. Esse problema foi estudo por Belady, um matemático húngaro, sendo chamado então de anomalia de Belady.
Thrashing ocorre quando a memória RAM não é suficiente para alocar todos os dados do conjunto ativo dos processos e fica alternando o estado de cada processo entre executando e suspenso para poder carregar as informações de outras fontes.
Computadores sem operações de entrada e saída de dados são basicamente inúteis. Por este motivo, existem enormes desafios em organizar, reconhecer, acessar, gravar e receber dados de diferentes dispositivos em um computador comum. Imagine que qualquer periférico com uma entrada padronizada, tipo USB, pode ser conectado em uma máquina, reconhecido e estar funcionando em questão de segundos. Qual a complexidade de fazer isso acontecer independente do hardware e do sistema operacional?
Do ponto de vista físico, os dispositivos conectados ao computador emitem um sinal elétrico analógico que deve ser convertido para sinal digital e armazenado em um buffer que poderá ser então acessado pela CPU através de um elemento chamado controlador de entrada. Do mesmo modo, para enviar dados ao dispositivo, utiliza-se um controlador de saída. As informações vêm e vão através de barramentos.
Periféricos complexos possuem microcontroladores ou processadores na placa que executa um código chamado de firmware e é independente do SO do computador.
Os barramentos modernos em computadores são divididos no chipset em north e south bridge. O primeiro lida com acesso à RAM e dispositivos de alta velocidade, além de estar ligado diretamente à CPU. O south bridge controla barramentos e portas mais lentas, como PCI, USB e SATA. A comunicação da CPU com a south bridge se dá pela north bridge. Essa arquitetura vem do padrão Von Neumann.
As portas, ou registradores, de entrada e saída dos dispositivos são divididas em: entrada, saída, status e controle. Elas formam a interface de acesso da CPU ao periférico. A forma de acesso pode ser mapeada em portas, mapeada em memória, híbrida ou por canais de entrada e saída.
O processador pode enviar um dado ao dispositivo pelo controlador, mas o contrário não é usual. Para o periférico acessar o processador é necessário o envio de uma interrupção (IRq - Interrupt Request), que são sinai elétricos pedindo a atenção da CPU. Quem recebe uma interrupção é o controlador de instruções programável (PIC - Programmable Interrupt Controller). Interrupções normalmente são números inteiros enviados pelo barramento que tem um caminho específico no chipset para que o processador suspenda uma atividade e executa a rotina de tratamento de exceção. As rotinas estão definidas em memória e podem ser encontradas através da tabela de interrupções (IVT - Interrupt Vector Table).
O software necessário para comunicar uma ação ao controlador do dispositivo externo é o driver. Cada periférico em determinado sistema operacional tem o seu driver. Os dispositivos são agrupados em classes para facilitar o tratamento de interfaces genéricas que sirva de comunicação com o SO. As classes comuns em UNIX são:
Um driver pode servir para um único dispositivo ou para uma família. São constituídos de funções de entrada e saída, gerência (do driver e do dispositivo) e tratamento de eventos. Os drivers tem estrutura de armazenamento local e tem acesso privilegiado ao núcleo do Sistema Operacional. Um driver pode interagir com o dispositivo através de algumas estratégias:
A notificação recebido por interrupção é tratada em dois níveis, primário e secundário. O primeiro registra e coloca o evento na fila, além de notificar o driver. O segundo lida com eventos pendentes registrados.
Os sistemas operacionais devem definir como disponibilizar os recursos do hardware para diferentes processos. Isso deve ser feito de maneira eficiente e transparente ao usuário, monitorando quais programas são mais custosos e executando diversas rotinas para manter a saúde do computador em todo instante.
Referências: