1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência da Computação Universidade Federal de Minas Gerais Caixa Postal 702 30161-970 - Belo Horizonte, MG tel: (031) 499 58 60/fax: (031) 499 58 58 e-mail: {kitajima, corelio}@dcc.ufmg.br Introdução A importância do processamento paralelo na busca por maior poder de computação está atualmente bem definida. O princípio do "trabalho cooperativo" é bastante intuitivo e pode ser diretamente aplicado (1) em novas arquiteturas de computadores, onde vários processadores trabalham simultaneamente na resolução de um problema específico, (2) em novos sistemas operacionais, que suportam processos concorrentes (em multiprogramação e/ou em multiprocessamento), e (3) em novas linguagens de programação, permitindo que soluções sejam expressas de acordo com um paradigma de programação concorrente e/ou paralela. Nas Jornadas de Atualização de Informática de 1995, o primeiro autor aborda o item (3), apresentando elementos de linguagens para programação distribuída (i.e., baseada na troca de mensagens) [KIT95]. Neste texto, os autores abordam o item (2), onde alguns mecanismos do sistema operacional de suporte à concorrência são apresentados, com reflexos, naturalmente, sobre as linguagens de programação disponíveis no sistema em questão. Três exemplos são apresentados: dois sistemas operacionais multithreaded (Solaris e Eindows NT) e uma linguagem atual de programação orientada a objetos, Java, que implementa classes associadas às threads. 1. Gerência de Processos Computadores (isolados, paralelos ou em rede) realizam tarefas. Estas tarefas podem ser de diferentes naturezas: por exemplo, a execução de um programa objeto compilado a partir de um programa fonte em C, a própria compilação deste programa, a própria edição do programa fonte em C, controle de temperatura de uma estufa, e gerência da memória do próprio computador e dos dispositivos de entrada e saída conectados a ele. Além do mais, estas tarefas podem estar em execução todas ao mesmo tempo, seja compartilhando um único processador, e, neste caso, falamos em multiprogramação, seja utilizando vários processadores, considerando assim a ocorrência de multiprocessamento ou, tão simplesmente, de execuções em paralelo. Sistemas Operacionais. Todo sistema computacional, exceto os mais primitivos ou os muito especializados, são dotados de um sistema operacional. Este sistema operacional possui duas funções básicas [TAN92]: 1. apresentar ao usuário do computador (seja um programador de linguagem de alto nível, seja um usuário de aplicativos) uma máquina estendida ou virtual. O sistema operacional funciona então como um intermediário entre as aplicações e o hardware: os detalhes reais do hardware tornamse transparentes para os usuários. Se um sistema operacional com tal função não existisse, um programador precisaria, por exemplo, saber ativar o barramento de controle e de dados de maneira apropriada a fim de realizar uma leitura de uma posição da memória principal e copiar o valor lido para outra posição da memória. Mesmo em linguagem de montagem (assembler), esta operação se resume, em geral, às seguinte instruções: LOAD (X) % registrador recebe valor da posição X da memória STORE (Y) % posição Y da memória recebe valor do registrador Compiladores e linguagens de comandos (ou shells) são outros componentes (não do sistema operacional) que complementam esta função do sistema operacional de “esconder” detalhes do hardware dos usuários finais dos computadores; 2. gerenciar os recursos de um sistema computacional, sejam recursos de software, sejam recursos de hardware. Por exemplo, a organização dos arquivos em disco, o controle da multiprogramação e controle de utilização da impressora. Sob este aspecto, o sistema operacional é visto como uma entidade de controle [SIL94]. 3 Um sistema operacional é um programa ou um conjunto de programas que implementam estas duas funções acima. Dentro de cada possibilidade (fornecer uma máquina virtual e gerenciar recursos), as atividades realizadas variam de sistema operacional para sistema operacional. Podemos encontrar sistemas operacionais completos, fornecendo todos os serviços imagináveis aos usuários, e sistemas operacionais menos potentes, onde alguns serviços devem ser realizados pelo próprio usuário. Exemplos de sistemas operacionais são Unix (e suas inúmeras variações), DOS, Windows NT, OS/2, MACH e outros menos conhecidos, como QNX, Amoeba, Chorus, e Helios. É importante observar que, por ser um ou mais programas, um sistema operacional é também composto de um conjunto de tarefas que são executadas pelo processador. Sistema operacional é software, embora algumas de suas funções possam ser implementadas em hardware. A Figura 1 apresenta uma visão modular e hierárquica de um sistema computacional. Usuário 1 Usuário 2 Usuário 3 Usuário N Programas de Aplicação Sistema Operacional Hardware do Computador Figura 1. Uma visão modular e hierárquica de um sistema computacional [SIL94]. Processos. Independente se as tarefas são de usuários típicos, de programadores ou do sistema operacional, elas são executadas pelo processador e são gerenciadas pelo próprio sistema operacional (neste sentido, o sistema operacional se executa utilizando os seus próprios mecanismos de controle). Toda tarefa em execução que envolve um programa objeto (código), dados e um estado é visto pelo sistema operacional como um processo sequencial ou, simplesmente, processo. Informalmente, um processo é um programa em execução [SIL94]. Um processo não é um programa. Um programa é um arquivo armazenado geralmente em um meio magnético ou óptico, escrito em uma linguagem de alto ou baixo nível. Um processo é um programa em execução. Uma receita de bolo é um programa. Fazer o bolo usando a receita é um processo. Processos são abstrações (da atividade da CPU) que são manipuláveis pelo sistema operacional. Um sistema operacional enxerga uma tarefa como um processo. Eventualmente, uma tarefa de um usuário é vista pelo sistema operacional como um conjunto de processos. Por exemplo, um processamento em lote (batch) envolve um job que se pode consistir em: (1) compilar um programa, (2) ligá-lo a outros módulos pré-compilados e (3) executar o programa objeto ligado final. Para o sistema operacional, este job é realizado através de três diferentes processos, um para cada etapa. Antes de entrar em detalhes sobre como processos são implementados, é importante observar que processos são criados por outros processos. Quando um computador é ligado, um programa de boot carrega módulos do sistema operacional que, a partir de sua execução, lançarão outros processos do sistema e suportarão processos de usuários. Qualquer processo, por sua vez, pode lançar novos processos, formando então uma estrutura hierárquica de processos. Processos também possuem estados e podem interagir com outros processos. Além disto, como visto acima, em um dado sistema computacional, podem existir vários processos em execução, normalmente, associados a diferentes usuários de um computador multiprogramado. O escalonador é o módulo do sistema operacional que decide a ordem de execução dos processos, dado que um ou mais processadores estão disponíveis. Processos podem ser preemptáveis, se eles podem ser interrompidos durante a sua execução a fim de que outro processo execute, ou processos podem ser não preemptáveis, se eles não podem ser interrompidos (ou seja, executam do início ao fim ou explicitamente se bloqueiam). Após a interrupção de um processo ou o seu fim, o escalonador deve decidir quem deve executar em seguida. Tomada a decisão, o “despachante” (em inglês, dispatcher) efetivamente ativa o processo escolhido para execução. Pelo fato de que processos podem interagir com outros processos, processos podem bloquear-se durante esta interação. Por exemplo, um processo, a fim de que possa continuar o seu trabalho, pode aguardar dados oriundos de outro processo. Enquanto espera, o processo está parado, bloqueado. Assim, é possível observar que processos podem estar em diferentes estados. Do discurso acima, três estados são citados: a) um processo em execução (running): é o processo cujas instruções o processador está correntemente executando; 5 b) um processo pronto para executar (ready): é o processo que pode ser executado pelo processador, mas não está em execução pois não foi escolhido ainda pelo escalonador; c) um processo bloqueado (blocked): como um processo pronto para executar, um processo bloqueado também está em espera, mas não para ser escolhido pelo escalonador. Ele espera a ocorrência de um outro evento, por exemplo, recepção de dados, a passagem de 5 segundos ou o término de uma operação de entrada e saída (I/O). Quando o evento ocorre, o processo muda de estado, passando de bloqueado para pronto para executar. A Figura 2 apresenta um diagrama de transição dos possíveis estados de um processo. O modelo de processos facilita a compreensão da dinâmica do computador. Uma outra visão possível é aquela baseada em interrupções: nesta abordagem, diferentes interrupções estão ocorrendo no sistema. A cada interrupção ocorrida, uma tarefa deve ser realizada. Esta tarefa pode ser uma tarefa em si ou parte de uma tarefa maior. Nesta abordagem, não é possível saber a quem pertence a tarefa e se ela é parte de uma tarefa maior. Por exemplo, um programa de usuário em execução em um sistema multiprogramado suportando a preempção é composto de várias pequenas tarefas, cada uma realizada entre duas interrupções do processador. Em outras palavras, parte desta tarefa é executada pelo processador, até que ocorra uma interrupção avisando que o tempo para aquela microtarefa se esgotou. O escalonador (outra tarefa) executa então e decide qual microtarefa deve ser executada em seguida. Perde-se, assim, a noção de unidade que o modelo de processo apresenta. Naquele modelo, existe um processo escalonador, um processo para cada tarefa que envolve um programa em execução. Na abordagem baseada em interrupções, tudo ocorre em função das interrupções: perde-se a noção de um todo que o processo representa. Running Blocked Ready Figura 2. Diagrama de transição de estados de um processo [TAN92]. Algoritmos de Escalonamento. Na literatura, existem vários algoritmos de escalonamento de processos. O mais simples adota uma política justa: o primeiro processo na fila de processos prontos executa. Esta política respeita a ordem da fila e considera que processos não são preemptados (interrompidos). Apesar de simples, não é comum. As políticas mais implementadas são baseadas em fatias de tempo (também chamada de quanta - plural de quantum de tempo) e em prioridades ou, mais comumente, uma mistura dos dois. Um processador executa um processo durante uma fatia de tempo. Ao término desta fatia, o processo é desescalonado e passa para o final da fila de processos prontos para executar. Um processo naturalmente pode não usar toda a fatia disponível: ele pode-se bloquear por algum motivo antes que a fatia termine. Esta estratégia baseada em fatias é conhecida como round-robin. Em um esquema de prioridades, o próximo processo a ser executado após um desescalonamento deve ter prioridade maior, sendo, eventualmente, um processo não preemptável (interrompível). Implementação de Processos. A fim de implementar o modelo de processos, o sistema operacional mantém uma tabela chamada de tabela de processos, com uma entrada por processo. As principais informações contidas nesta tabela são (tomando Unix como exemplo): • informações relativas à gerência de processos ∗ valor dos registradores ∗ contador de programa (PC - program counter) que contém o endereço da próxima instrução a ser executada ∗ palavra de status do programa ∗ endereço da pilha do programa ∗ estado do processo (executando, pronto para executar, bloqueado) ∗ momento em que o processo iniciou ∗ tempo utilizado de processador ∗ tempo de processador utilizado pelos processos filhos (processos gerados pelo processo corrente) ∗ endereços para fila de mensagens ∗ bits de sinais pendentes ∗ identificador do processo (PID - Process ID) • informações relativas à gerência de memória ∗ endereço do segmento de texto 7 ∗ endereço de segmento de dados inicializados ∗ endereço de segmento de dados não inicializados ∗ status de saída ∗ status de sinalização ∗ identificador do processo (PID - Process ID) ∗ identificador do processo pai ∗ identificador do grupo do processo ∗ identificador real e efetivo do usuário ∗ identificador real e efetivo do grupo • informações relativas à gerência de arquivos ∗ máscara de acesso aos arquivos ∗ diretório raiz ∗ diretório corrente ∗ descritores de arquivos ∗ identificador efetivo do usuário ∗ identificador efetivo do grupo do usuário ∗ parâmetros de chamadas ao sistema Cada processo tem uma entrada na tabela de processos contendo todas ou parte das informações acima. Em geral, esta tabela fica residente em memória principal, cache ou mesmo em registradores especiais do processador, dependendo de características arquiteturais do computador. Quando um processo é desescalonado pelo escalonador (“perde a vez”), todas as informações pertinentes a este processo devem ser salvas na tabela de processos. O sistema operacional deve carregar, a partir da tabela de processos, o estado do próximo processo a carregar. A tabela de processos mantém informações sobre os processos. Mas o que é o processo propriamente dito? Ora, um processo é um programa em execução que consome principalmente dois recursos: processador e memória. Instruções e dados devem residir em cache, memória principal e mesmo em memória secundária (disco magnético, normalmente) quando o sistema dispõem de memória virtual. O espaço de memória ocupado por um processo é chamado de área de trabalho (workspace) e contém basicamente (usando novamente o modelo Unix de processo): 1. um segmento de texto: contém as instruções a serem executadas em linguagem de máquina; 2. um segmento de dados: contém os dados do programa. É composto de duas partes, um segmento de dados inicializados (em geral, constantes) e um segmento de dados não inicializados, cujo espaço não é alocado quando o processo inicia. Por esta razão, o segmento de dados não inicializados possui tamanho variável (pode ocorrer alocação dinâmica de memória); 3. um segmento de pilha (stack): contém variáveis do ambiente de execução, o string contendo a linha de comando e endereços de retorno de procedimentos. A Figura 3 apresenta o espaço de endereçamento de um processo Unix em execução. BSS corresponde à área de dados não inicializados de um processo Unix. Pilha BSS Dados Código Figura 3. Espaço de endereçamento de um processo típico em Unix [TAN92]. Em um sistema multiprogramado, diferentes processos se alternam na utilização do processador. O processador que executa um programa contém em seus registradores dados daquele programa, 9 eventualmente dados de controle (parte de tabelas), o contador corrente do programa e o espaço de trabalho (completo ou parcial) na memória. A tabela de processos está também na memória. Quando a fatia de tempo correspondente àquele processo termina (em um escalonamento baseado em fatias de tempo) ou quando uma operação causadora de bloqueio é executada, o processo deve ser desescalonado. Isto implica em salvar o contexto deste processo, ou seja, salvar em memória todas as variáveis de estado daquele processo. Em geral, isto implica em salvar conteúdo de registradores e tabelas. Não necessariamente o espaço de trabalho daquele processo sai da memória. Aliás ele deve ficar, visto que, em caso de desescalonamento por término da fatia de tempo, o processo voltará a executar em breve. Se o processo é bloqueado por alguma outra razão diferente do término da fatia de tempo, o processo é colocado em estado de bloqueado, aguardando algum evento a fim de que ele se desbloqueie (por exemplo, fim de uma operação de entrada e saída). Este salvamento de contexto possui tempos na ordem do milissegundo (se o salvamento for em disco) ou da ordem do microssegundo (se o salvamento for em RAM) e, se não for devidamente eficiente, pode comprometer o desempenho do sistema como um todo. Suponha agora que tal sistema multiprogramado seja utilizado para programação concorrente e pseudo-paralela. “Pseudo-paralela” pois, inicialmente, é considerado que se dispõem de um único processador. Com um processador, não se pode ter paralelismo real. Em um sistema monoprocessado, multiprogramado, vários processos devem cooperar a fim de resolver um único problema. Normalmente, um sistema multiprogramado suporta diferentes tarefas de diferentes usuários executando de maneira concorrente. Assim, temos uma edição de textos de um usuário X, em concorrência com uma compilação de um usuário Y, em concorrência com um shell (interpretador de comandos) de um usuário Z, e assim por diante. Nada impede de termos vários processos resolvendo um único problema de um único usuário. Isto não parece tão distante assim do quotidiano. Um ambiente de janelas normalmente funciona desta maneira. Cada janela tem um processo associado em execução. Existe um processo pai, normalmente o shell de origem, que, a pedido do usuário, lança diferentes processos correspondentes a diferentes janelas. Tudo isto gerenciado por um programa de controle de janelas. A comunicação, em geral, ocorre dos diferentes processos filhos para o processo pai, com fins de notificação de eventos. O gerenciador de janelas deve-se manter informado de tudo o que ocorre na tela. A mesma abordagem poderia ser utilizada para resolver um problema menos visual e mais científico, por exemplo, uma multiplicação de matrizes ou uma simulação numérica. O objetivo é explorar o máximo de concorrência possível para ir mais rápido. Um processo que se bloqueia por entrada/saída pode ceder o processador para outro processo “irmão” que o está ajudando a resolver o mesmo problema. Em sistemas multiprogramados tradicionais, como Unix, apesar da cooperação, estes processos, para o sistema operacional, não são diferentes dos outros processos em execução dos outros usuários. Suponha que dois processos A e B cooperem para resolver um dado problema arbitrário X. Se A é desescalonado, nada é garantido se o próximo processo a ser executado é B. Pode ser, pode não ser, depende do número de processos em execução e, principalmente, da política de escalonamento adotada pelo sistema operacional. Eventualmente, uma prioridade mais alta pode ser concedida a A e a B, a fim de que eles, em concorrência com o sistema operacional, sempre executem, antes de qualquer outro processo de outro usuário. Mas, isto envolve um acordo externo ao sistema (o “super usuário” pode modificar a prioridade de processos: um usuário comum consegue abaixar a prioridade de seu processo, jamais subi-la). Assim, além de contar com um chaveamento de contexto nada diferente em relação ao chaveamento de contexto de outros processos, estes processos não compartilham o processador em conjunto. Outro aspecto a ser considerado é a separação das áreas de trabalho entre os diferentes processos, sejam eles cooperantes ou não (independentes). Um processo não tem acesso à área de trabalho de outro processo. Se um processo A precisa de dados de outro processo B, o processo B pode, por exemplo, escrever o dado em um arquivo e, em seguida, o processo A ler o arquivo com o dado. Outra possibilidade é o processo A enviar uma mensagem para B solicitando o dado e aguardar o processo B enviar para A uma outra mensagem com o dado solicitado. Estas são as duas maneiras básicas para dois processos comunicarem (a última depende de um suporte do sistema operacional para troca de mensagens). Qualquer outra maneira corresponde a uma variação dos métodos acima. A área compartilhada para comunicação pode ser um registrador, memória principal ou disco (no caso de arquivos). Em ambientes monoprocessados, a troca de mensagem entre processos também será feita através de uma memória compartilhada que conterá a mensagem a ser transmitida. Porém, se os processos residem em dois processadores distintos, interconectados por uma rede, então a mensagem trafegará pelo meio de interconexão (e.g., um barramento), constituindo uma mensagem, propriamente dita, enviada de uma máquina a outra. Quando dois processos se comunicam através de uma área comum, o acesso a esta área deve ser controlada de modo a permitir o acesso único por 11 um processo. A posição de uma memória pode ficar inconsistente (e a instrução associada), se dois ou mais processos tentarem atualizar esta posição de memória concorrentemente. Situações como esta podem levar às chamadas situações de corrida. Um exemplo é apresentado na Figura 4. Para contornar este problema de compartilhamento, diferentes mecanismos podem ser implementados, tanto em hardware quanto em software, através de primitivas de baixo nível ou primitivas mais abstratas. A literatura é extensa [SIL94][TAN92]: instruções do tipo TSL (Test and Set Lock), primitivas sleep e wakeup, semáforos, contadores de eventos, primitivas lock (tranca), monitores e mesmo mensagens (uma comunicação através de mensagens estabelece, por si, uma ordem de acesso ao dado: primeiro o dado é enviado e posteriormente recebido, jamais o contrário). Em algum momento, estas primitivas garantem uma atomicidade de execuções de primitivas de sincronização entre processos. Processo 1 counter :=2; counter := counter+1; .... Processo 2 counter := 0; counter := counter-1; ..... Se a operação “counter := counter+1;” é decomposta em: registrador1 := counter; registrador1 := registrador1+1; counter := registrador1; e a operação “counter := counter-1;” é decomposta em: registrador2 := counter; registrador2 : = registrador2-1; counter := registrador; o processo pode ser desescalonado entre qualquer uma das sub-instruções acima, levando a possíveis diferentes valores de “counter” após a execução concorrente do processo 1 e do processo2. Figura 4. Um exemplo de situação de corrida. O esquema de processos “fechados”, comunicando-se através de memória compartilhada ou através de mensagens, é adequado? Algumas vantagens e desvantagens podem ser levantadas: Vantagens: ∗ o espaço de trabalho de cada processo é devidamente protegido pelo sistema operacional que não permite que outros processos (sejam cooperantes ou não) obtenham acesso a espaços de trabalho de outros processos; ∗ um processo que participa em um grupo de processos cooperantes pode, por algum motivo, “morrer” sem afetar fisicamente os outros processos. Os processos cooperantes podem ficar todos bloqueados caso um ou mais deles “morram”. Mas se a aplicação for tolerante a falhas, dentro do próprio programa, existem mecanismos que prevêem este fenômeno. Os outros processos se adaptam em função da ausência do elemento falho. Desvantagens: ∗ suponha dois processos A e B cooperando para a realização de uma tarefa. Se o processo A, em execução, se bloqueia por algum motivo, logo no início da fatia de tempo a ele alocada, o outro processo B, que também auxilia a resolver o mesmo problema de A, provavelmente não será escalonado para preencher o resto da fatia não utilizado por A. Como não é possível prever com antecedência qual será o próximo conjunto de instruções a ser executada por um processo, nada se pode afirmar sobre quando um processo perderá o processador. Se se dá 10 unidades de tempo para um processo se executar e ele só executa uma unidade de tempo destes 10, por que não dar as 9 unidades de tempo restantes para outro processo cooperante associado? ∗ se fosse possível ordenar diferentes processos envolvidos na mesma computação de maneira consecutiva, o problema acima estaria resolvido. Porém, o tempo de chaveamento de contexto poderia ainda ser considerado alto. A pergunta que resta é: não seria possível reduzir o tempo de chaveamento de contexto destes processos, sabendo que estes processos cooperam? Será que, pelo fato deles cooperarem, alguma simplificação na implementação destes processos não poderia ser idealizada? ∗ uma forma de comunicação entre processos passa por um arquivo. Mesmo quando isto não é explícito (por exemplo, usando o pipe do Unix), o sistema de arquivos é acionado. Um acesso a arquivo é, em geral, muito mais lento do que um acesso puro à memória. 13 Em face destas vantagens e desvantagens, duas soluções poderiam ser adotadas: 1. reduzir o tempo de chaveamento de contexto e implementar mecanismos mais eficientes para comunicação entre processos 2. permitir que um processo lance outros processos com áreas de acesso comuns para comunicação e que estes processos filhos compartilhem em grupo a mesma fatia de tempo alocada ao processo pai É importante observar que a criação de processos é uma tarefa realizada com muita frequência. O problema é que os processos não compartilham nada entre si, quando muito, o segmento de texto (código), que é, por natureza, não modificável na maior parte dos casos. Existem esforços em direção à primeira solução: manter a proteção existente entre processos e realizar um chaveamento de contexto eficiente. O hardware pode auxiliar nesta operação. Resta resolver o problema do compartilhamento da fatia de tempo entre processos correlatos. Isto pode ser imposto automaticamente pelo sistema operacional, modificando a estratégia de escalonamento do sistema operacional e criando um campo na tabela de processos que indica se o processo participa de um grupo de processos cooperantes. Com tantas modificações, um novo tipo de processo pode ser idealizado: o processo leve, também conhecido como thread. É o que será visto em seguida. 2. Processos Leves (“Threads”) Segundo Feitelson e Rudolph [FR90], um sistema paralelo deve suportar dois tipos de modelos de processos. Os processos, ditos pesados, são aqueles mencionados na seção anterior. “Eles permitem um projeto estruturado e modular de grandes sistemas, criando contextos distintos para cálculos independentes, separados e protegidos uns dos outros. Isto naturalmente é válido para usuários independentes. Threads permitem um paralelismo de granularidade mais fina; muitas threads podem existir dentro de um contexto de um processo, cooperando entre si a fim de realizar um dado cálculo e compartilhando o espaço de endereçamento, arquivos abertos, etc.” [FR90]. Por granularidade mais fina, subentende-se que a unidade de cálculo realizada de maneira concorrente é menor do que a unidade de cálculo associada a um processo. Por exemplo, a granularidade de um processo é de programa. A granularidade de uma thread pode ser de um procedimento dentro de um programa: isto é, procedimentos podem ser executados concorrentemente. A Figura 5 apresenta graficamente a diferença entre estas duas abstrações. Thread Contador de Programa Processos Pesados Figura 5. Processos e threads [TAN92]. É importante observar que um processo pesado visto anteriormente é composto de apenas uma thread (vide Figura 5). Por exemplo, um processo que corresponde à execução de um programa compilado em C, possui uma thread de controle que começa no main(), passa por todas as instruções do programa, inclusive instruções embutidas em procedimentos, e termina no último fecha-chaves do programa (fechando o bloco iniciado pelo main). Threads podem ser conhecidas também como processos leves. Em ambientes multiprocessados, diferentes threads podem ser executadas realmente em paralelo em diferentes processadores. Threads têm-se tornado populares porque possuem algumas características de processos pesados, mas podem ser executadas mais eficientemente [SIL94]. Histórico. A noção de uma thread, como um fluxo sequencial de controle, data de 1965, pelo menos, com o Berkeley Timesharing System. Naquela época, eram chamados de processos e não de threads. Processos interagiam através de variáveis compartilhadas, semáforos e mecanismos análogos. Max Smith desenvolveu um protótipo de implementação de threads sobre o sistema operacional Multics em 1970. Ele utilizou múltiplas pilhas em um processo pesado para suportar compilações em background. Talvez o ancestral mais importante das threads é a primtiva suportada pela linguagem PL/I, de cerca de 1965. A linguagem como definida pela IBM proporcionava uma chamada do tipo: 15 CALL XXX (A, B) TASK; que criava uma thread para XXX. Não está claro se os compiladores da IBM implementaram esta possibilidade, mas foi examinada em detalhes quando do desenvolvimento de Multics. Foi decidido que a chamada TASK como definido não mapeava em processos, desde que não havia proteção entre threads de controle. Assim, Multics tomou uma direção diferente, e a chamada TASK foi removida de PL/I pela IBM. Em seguida, surgiu Unix no início dos anos 70. A noção Unix de um processo transformou-se em uma thread única de controle sequencial mais um espaço de endereçamento virtual (incidentalmente, a noção Unix de processo derivou diretamente dos processos no projeto do Multics). Assim, processos em Unix são “máquinas assaz pesadas”, desde que eles não podem compartilhar memória entre si (cada processo tem o seu espaço de endereçamento, podendo comunicar-se através de pipes ou de mensagens). Após um certo tempo, usuários de Unix começaram a sentir falta dos velhos processos que compartilham memória. Isto levou às threads como as conhecemos hoje. O termo “leves” (lightweight) surgiu em finais da década de 70 ou início da década de 80, junto com os primeiros microkernels (Thot, Amoeba, Chorus, Mach). Como observação, é colocado que threads têm sido utilizadas em aplicações de telecomunicações por um longo tempo [NWS96]. O FAQ (Frequently Asked Questions - questões frequentemente colocadas: documento de um newsgroup Internet com as dúvidas mais comuns dos leitores daquele newsgroup) não comenta sobre a linguagem Algol (pelo menos da sua implementação nas máquinas de pilha da Burroughs) na qual threads concorrentes eram disparadas dentro de aplicações, sejam como co-rotinas (veja abaixo), sejam como threads assíncronas. Implementação de threads. Cada processo leve deve ter seu próprio contador de programa e sua própria pilha. Podem conter também uma área de dados privados. Threads podem criar outras threads e, como processos pesados, podem bloquear-se na espera de um evento. Threads compartilham o processador, da mesma maneira que processos pesados. Porém, diferentemente de processos pesados, uma thread bloqueada pode ceder o processador a outra thread do mesmo processo. Embora tenham seu próprio PC, sua pilha e dados privados, as threads se executam sobre um mesmo espaço de endereçamento, compartilhando variáveis globais se for o caso. Segundo Tanenbaum, não há proteção alguma entre threads, pois (1) é impossível (visto que todas elas atuam dentro do espaço de um único processo), e (2) em geral, não é necessário, pois estes processos leves estão exatamente cooperando para resolver um problema comum e pertencem a um mesmo usuário. Threads possuem estados também, naturalmente: em execução, prontas para executar, bloqueadas e terminadas. Dentro deste modelo, é necessário caracterizar um processo leve como terminado, visto que outras threads podem estar em execução e o espaço de trabalho deixado pela thread que terminou ainda não tenha sido recolhido pela thread pai. Uma tabela de threads deve então ser mantida também. Os itens por thread são normalmente: • o contador de programa; • o endereço da pilha; • o conjunto de registradores associados; • endereços das threads filhas; • estado. Resta para o processo, como um todo, informações do tipo endereço da área de trabalho, variáveis globais, apontadores para informações de arquivos abertos, endereços de processo filhos, informações sobre timers, sinais, semáforos e de contabilização. Threads podem ser síncronas ou assíncronas. Quando elas são síncronas, elas executam até que elas mesmas decidam não continuar a execução (ou termine a fatia de tempo para aquele processo que, ao voltar a executar, continuará executando a mesma thread interrompida). Isto facilita o mecanismo de compartilhamento de dados, pois jamais uma thread será interrompida, se ela não o quiser explicitamente. Threads assíncronas, por outro lado, podem executar umas com as outras, por exemplo, subdividindo a fatia de um processo equitativamente entre as threads ativas (não bloqueadas). Modelos de utilização de threads. O padrão de comportamento das threads dentro de um processo pode ser o mais variado possível. Por exemplo, Tanenbaum apresenta três organizações diferentes: a mestre/escravo, um modelo baseado em time ou equipe e um modelo baseado em pipeline. Na primeira configuração, existe uma thread mestre que recebe tarefas a serem realizadas e que as despacha para outras threads que efetivamente realizarão as tarefas (uma por thread, por exemplo). Um servidor de arquivos pode ser estruturado desta maneira. Existe uma thread que recebe pedidos 17 de abertura de arquivos. Para cada arquivo, o mestre designa uma thread escrava que se encarregará de gerenciar um arquivo cuja abertura foi solicitada. É aquela thread escrava que lerá ou escreverá no arquivo a qual ela é responsável. No modelo de time, é possível imaginar threads com habilidades diferentes: por exemplo, uma somente lê arquivos, outra somente realiza cálculos com números inteiros, outra acolá somente realiza operações sobre números com ponto flutuante. Da mesma maneira que uma equipe de operários pode levantar uma casa, as diferentes threads podem resolver um dado problema. O modelo em pipeline (ou em duto) é análogo a uma linha de montagem, onde diferentes threads, também especializadas, realizam diferentes operações sobre dados que são passados de thread em thread, como um carro sendo montado em uma linha de produção. Pacotes de threads. As primitivas de manipulação de threads são em geral disponíveis através de bibliotecas ou de pacotes (thread packages). Um primeiro problema a ser abordado é se threads são criadas estatica ou dinamicamente. No caso estático, o número de threads é definido em tempo de “redação” ou de compilação do programa. A pilha alocada para cada thread é de tamanho fixo. No caso dinâmico, que é o mais habitual, threads são criadas sob demanda pelo programa. Quase sempre, o nome da primitiva de criação de threads envolve o termo fork, também bastante utilizado para criação de processos pesados. Entre diversos parâmetros, os mais importantes são o nome do procedimento que está sendo associada a thread, parâmetros para o procedimento, tamanho da pilha e até mesmo uma prioridade de escalonamento, prioridade está válida somente dentro do contexto do processo com a thread pai. É possível criar um esquema de prioridades entre threads de um mesmo processo. O processo, por sua vez, possui uma prioridade externa usada pelo escalonador do sistema operacional. A prioridade do processo, como vimos, em geral não é modificada pelo usuário. Assim como processos, threads podem terminar normalmente com a execução do fim do procedimento, ou podem ser “mortas” por outras threads. Threads podem comunicar-se através das variáveis globais do processo que as criou. A utilização destas variáveis pode ser controlada através de primitivas de sincronização (monitores, semáforos, ou construções similares). Primitivas existem para bloqueio do processo que tenta obter acesso a uma área da memória que está correntemente sendo utilizada por outro processo. Primitivas de sinalização de fim de utilização de recurso compartilhado também existem. Estas primitivas podem “acordar” um ou mais processos que estavam bloqueados. É importante observar que variáveis globais, embora úteis em alguns contextos, devem ser evitadas em outros. Valores de status de operações de entrada/saída são em geral armazenadas em variáveis globais. O exemplo de [TAN92] é a variável errno que é global a todas as threads. Se uma thread realiza uma abertura de arquivo para leitura e algum problema ocorre (e.g., o arquivo não existe), a variável errno conterá um código de operação inválida. Porém, antes de testá-la para tomar alguma providência, a thread é desescalonada e outra thread passa a ser executada pelo processador. Esta mesma thread realiza outra operação de entrada e saída com sucesso e a mesma variável errno conterá o valor de um código válido de status. Quando a outra thread voltar a executar ela “enxergará” um valor errôneo da variável status. Implementação de pacotes de threads. [TAN92] apresenta duas maneiras possíveis de implementar threads: uma no espaço do usuário e outra no espaço do sistema operacional. Na primeira opção, o kernel do sistema operacional não tem conhecimento da existência das threads. Para o kernel, existem processos pesados que são executados intercaladamente. A vantagem é que pacotes deste tipo podem ser utilizados em sistemas operacionais que não suportam threads (por exemplo, Unix padrão como SunOS). O escalonamento das diferentes threads é gerenciado por uma camada de software abaixo das threads e acima do kernel. Chamadas de sistema não são realizadas: toda a gerência da execução das threads é feita pelo run time system (veja Figura 6). Outras vantagens são a flexibilidade do escalonamento (o usuário pode até mesmo programar o seu próprio algoritmo) e a extensibilidade ou escalabilidade (scalability): se as threads fossem todas gerenciadas pelo kernel, este se tornaria um gargalo e o desempenho cairia com o aumento do número de threads. Em pacotes de threads gerenciados pelo sistema operacional, não há necessidade de uma camada adicional de software. Para cada processo ativado, existe uma tabela de threads com informações sobre o seu estado. Esta tabela também existe no caso de pacotes a nível de usuário, mas a mesma agora se encontra em um espaço de trabalho do sistema operacional. Qualquer operação relativa a uma thread é implementada com uma chamada de sistema, o que envolve um overhead maior. Quando uma thread se bloqueia, o sistema pode executar outra thread do mesmo processo ou uma thread de outro processo. 19 Threads Threads User Space Run time system Kernel User-level threads package Kernel Space Kernel Kernel-level threads package Figura 6. Pacotes de threads a nível de usuário e a nível de sistema [TAN92]. Comparando as duas abordagens, observa-se que threads a nível de usuário jamais podem bloquear. Se uma delas bloqueiam, todas as outras ficam bloqueadas e o processo como um todo é desescalonado. Uma solução seria dispor de chamadas de sistemas não bloqueantes, mas isto envolveria mudanças no sistema operacional subjacente. Pode-se descobrir antes de executar uma instrução se ela vai bloquear ou não (algo como um comando do tipo probe - ou sonda). Se uma instrução vai bloquear, o pacote pode então decidir executar outra thread. Este comando de sonda implica em modificações na biblioteca de chamadas de sistema. Jacket é o nome dado ao código associado “em torno” de uma chamada de sistema a fim de fazer esta verificação. Escalonamento round-robin não é possível entre threads executando dentro de um quantum de tempo do processador, pois interrupções de relógio não ocorrem neste intervalo (a não ser que seja explicitamente solicitada tal interrupção). Isto quer dizer que se uma thread começa a executar, ela o fará até terminar a fatia de tempo alocada para o processo como um todo ou a thread explicitamente se bloquear ou passar para o estado ready. Threads são necessárias em aplicações onde uma concorrência pode ser explicitada e ocorra muitos bloqueios (por exemplo, uma aplicação I/OBound, que realiza muita entrada/saída e pouco cálculo). Outros problemas podem ocorrer para ambos os casos: problema de código de biblioteca compartilhado e dados globais por processo. Reentrância. O desenvolvimento de aplicações com múltiplas threads requer um ambiente com suporte a compartilhamento de código (ou reentrância) entre threads. Solaris, por exemplo, proporciona versões reentrantes para a maioria das bibliotecas comumente utilizadas. Correntemente, Solaris não proporciona versões seguras a threads das bibliotecas Motif e OpenLook, que são raramente utilizadas por múltiplas threads em um programa. Windows NT também proporciona versões reentrantes para a maioria de suas bibliotecas de uso comum. Depuração. A depuração de aplicações multithreaded é um grande desafio e pode ser frustrante sem o suporte de um depurador ciente de threads. Solaris suporta um depurador multithreaded como parte de seu ambiente SPARCworks/iMPact, enquanto o depurador NT multithreaded faz parte de Visual C++. Além de mostrar as threads por processo, ambos os depuradores suspendem e resumem threads e inspecionam variáveis por thread. Co-rotinas. Threads que não são preemptiveis e podem somente ser um único fluxo ativo de controle dentro de um processo (não importando o número de processadores disponíveis) são referidas como co-rotinas. Programação com co-rotinas requer uma abordagem bem diferente da programação baseada em threads. Isto porque os problemas de sincronização e de compartilhamento de recursos que ocorrem em ambientes com threads não perturbam o programador de co-rotinas. Threads e Sistemas Distribuídos e Concorrentes. As threads são de interesse particular em sistemas distribuídos e concorrentes. Como vimos, módulos em sistemas concorrentes são, em geral, mais complexos do que módulos em sistemas sequenciais. Por exemplo, um procedimento pode estar associada a uma ou mais threads concorrentes que interagem através de uma memória comum ou através de passagem de mensagens. Assim, uma relação simples de entrada e saída (característica de procedimentos de programas sequenciais) não é adequada para descrever o comportamento de um procedimento, desde que os seus estados intermediários internos podem estar visíveis aos “clientes” através de outras interações. Interfaces em procedimentos concorrentes incluem não somente pontos de entrada e de retorno, mas também pontos intermediários que podem interagir com outras threads. Módulos em sistemas concorrentes podem também ser ativos: eles podem ter threads internas em background, cujo efeito em algum lugar deve ser descrito em uma especificação [WEI93]. Futuro. Outros sistemas operacionais são multithreaded: NextStep, OS/2, AIX (e outros Unix), e Windows95. Versões futuras do sistema operacional de Macintosh serão também multithreaded. 3. Exemplos 21 A seguir serão apresentados exemplos de sistemas operacionais e linguagens que suportam o conceito de threads. Serão apresentados aspectos de implementação e, em seguida, exemplos de utilização. 3.1 Solaris Histórico. Solaris é a nova denominação dada, a partir de 1992, aos sistemas operacionais Unix das estações de trabalho Sun (anteriormente, eram conhecidos como SunOS - a versão 4 do SunOS corresponde ao Solaris 1). A última versão de Solaris é a 2.5.1, capaz de ser executada, pela primeira vez, em outras plataformas diferentes das estações Sun (UltraSPARC/SPARC). Esta versão está disponível também para os microprocessadores Intel e PowerPC. Objetivos. Solaris 2 é um sistema operacional que suporta threads a nível de sistema e a nível de usuário, multiprocessamento simétrico e suporte para aplicações de tempo real. Com a versão 2.5.1, este objetivo é alcançado considerando diferentes tipos de arquiteturas (SPARC, Intel, PowerPC). Implementação. As bibliotecas de threads a nível de usuário suportam basicamente a criação e o escalonamento de threads e o kernel não toma conhecimento destas threads. Entre as threads dos usuários e a dos kernel, existe um nível intermediário correspondente ao, no contexto de Solaris 2, chamado de processos leves (lightweight processes - LWP). Cada processo em Solaris 2 contém ao menos um processo leve. Estes processos leves são manipulados pela biblioteca thread. Threads a nível de usuários são “multiplexadas” por LWPs do processo. Estas threads dos usuários podem ou não estar acopladas a uma LWP (serem bound ou unbound). Se elas não estiverem acopladas a uma LWP, nenhum trabalho pode ser realizado. Assim, threads a nível do usuário podem disputar por uma LWP. Por outro lado, instruções dentro do kernel são executadas por threads a nível de sistema. A cada LWP, existe uma thread do sistema e podem existir threads do sistema que trabalham em prol do sistema operacional e não possuem LWP associada (por exemplo, uma thread que serve pedidos de disco). As threads do kernel são efetivamente os únicos objetos escalonáveis no sistema. Solaris 2 suporta multiprocessamento, então diferentes threads podem executar-se sobre diferentes processadores. Threads podem obter exclusividade sobre um processador: este processador somente executa esta thread (veja Figura 7). User-Level Thread Lightweight Process Kernel Thread Kernel CPU Figura 7. Threads em Solaris 2 [SIL94]. Qualquer processo pode ter várias threads a nível de usuário. Estas threads a nível de usuário podem ser escalonadas e controladas (de maneira alternada) entre diferentes LWP sem intervenção do kernel. Não há chaveamento de contexto global quando uma thread se bloqueia e outra continua a executar, de modo que threads a nível de usuário são bastante eficientes. Para estas threads a nível de usuário, as LWPs somente são necessárias quando aquelas precisam comunicar-se com o kernel. Se existem, por exemplo, 5 threads bloqueadas por uma leitura em disco, então devem existir 5 LWPs. Se existem somente 4 LWPs, uma thread do usuário espera o término de uma operação de leitura de outra thread. Ela se bloqueia não porque fez uma chamada de sistema, mas porque não existe LWP livre para executá-la. Uma LWP contém um bloco de controle de processo com dados de registradores, informação de contabilização e de utilização de memória. O chaveamento entre LWPs é mais lento do que o chaveamento entre threads do kernel. Uma thread a nível de usuário somente necessita de uma pilha e um contador de programa. Se uma thread do kernel se bloqueia, a LWP associada (se existir alguma) ficará bloqueada também, assim como a thread do usuário associada. Uma thread do kernel possui somente uma pequena estrutura de dados e uma pilha. O chaveamento entre threads do kernel não requer a mudança de informações de acesso à memória, e portanto é relativamente rápido. 23 Interface. As threads em Solaris possuem uma API (Application Programming Interface) baseada na interface do Unix International, e suporte para interface Posix para threads está disponível no Solaris 2.5. A interface Solaris é muito semelhante à interface Posix, e aplicações desenvolvidas usando a API de Solaris podem ser facilmente portadas para usar a interface Posix. Identificadores de threads em Solaris são garantidos únicos dentro do contexto do processo. Exemplos. É apresentado um extrato de um programa que implementa o produtor-consumidor [JUN96]. Neste problema, existem um processo que produz um dado (no exemplo, inteiros) em um buffer de tamanho limitado (no exemplo, 10 posições) e um processo que lê dados do buffer e que faz um tratamento arbitrário sobre o dado lido. Um produtor não produz quando o buffer está cheio e o consumidor não consome quando o buffer está vazio. Além disto, o acesso ao buffer (para leitura e excritura) deve ser exclusivo. #include <thread.h> #include <synch.h> #include <stdlib.h> #define MAXTAM 10 /* inclusão da biblioteca thread.h */ sema_t *mutex; sema_t *empty; sema_t *full; int buffer[MAXTAM]; int nextp, nextc; void *Produtor(void *arg) { int item; int f; for(f = 1; f < 10*MAXTAM; f++) { item = rand(); sema_wait(empty); buffer[nextp] = item; sema_post(full); nextp++; nextp = nextp % MAXTAM; } } void *Consumidor(void *arg) { int item; int f; for(f = 1; f < 10*MAXTAM; f++) { sema_wait(full); item = buffer[nextc]; sema_post(empty); nextc++; nextc = nextc % MAXTAM; } } void main() { int valor; thread_t *consum; /* aloca espaço para os semáforos */ mutex = (sema_t *) malloc(sizeof(sema_t)); empty = (sema_t *) malloc(sizeof(sema_t)); full = (sema_t *) malloc(sizeof(sema_t)); /* inicializa semáforos */ sema_init(mutex, 1, USYNC_THREAD, NULL); sema_init(full, 0, USYNC_THREAD, NULL); sema_init(empty, MAXTAM, USYNC_THREAD, NULL); nextc = nextp = 0; /* define o nível de concorrência */ printf("Nivel de concorrencia %d\n", thr_getconcurrency()); printf("Novo valor: "); scanf("%d", &valor); thr_setconcurrency(valor); printf("Novo valor: %d\n ", valor); consum = ( thread_t *) malloc(sizeof(thread_t)); /* lança threads */ thr_create(NULL, 0, Produtor, NULL , 0, NULL); thr_create(NULL, 0, Consumidor, NULL , 0, consum); thr_join(*consum, NULL, NULL); } As funções de gerência de threads começam com o prefixo thr_. Os dois procesimentos são lançados pelo procedimento thr_create cujos parâmetros são: • base da pilha (default NULL); • tamanho da pilha (quando vale 0, pega o tamanho default - 1 Megabyte); • nome do procedimento; • argumentos; • flags (define o estado e tipo da thread. Por exemplo, THR_SUSPENDED quando criada em estado suspenso); • novo ID da thread (o procedimento produtor não tem ID. O procdeimento consumidor tem o ID armazenado na variável consum). 25 3.2 Windows NT O sistema operacional Microsoft Windows NT foi projetado desde o início para suportar multiprocessamento e multithreading (suporte às threads). A partir de conceitos de orientação a objetos, Windows NT usa classes de objetos para representar os recursos do sistema. Em Windows NT, instâncias da execução de um programa são também chamadas de processos. Um processo possui seu próprio espaço de endereçamento e recursos (memória, arquivos abertos e, mais tipicamente, janelas). A chamada de criação de processo é CreateProcess, com a qual uma thread é automaticamente construída para um processo. A criação de threads adicionais é realizada através da rotina CreateThread. A thread recém-criada inicia executando uma rotina especificada por um parâmetro de CreateThread. Cada thread em NT tem sua própria pilha (de usuário e de sistema), sendo que o tamanho da pilha desta recém-criada thread pode ser também especificada na rotina CreateThread. Processos e threads são representados como objetos. Ao contrário de Solaris, NT usa um mapeamento um-para-um entre threads do usuário e threads do sistema. Threads podem ser de tempo real e variáveis. Threads em tempo real em NT são sempre escalonadas antes das outras threads do sistema e o kernel NT não altera as prioridades das threads de tempo real. Threads de tipo variável têm uma prioridade dinâmica e uma base. A prioridade de base de uma thread varia dois níveis acima ou abaixo da prioridade de base do processo. O kernel periodicamente ajusta a prioridade dinâmica da thread. Por exemplo, quando uma thread espera por um I/O, o kernel aumenta a prioridade dinâmica daquela thread. Threads que são CPU-bound tendem a ter prioridades dinâmicas mais baixas. A prioridade dinâmica da thread jamais cai abaixo da prioridade de base desta thread. Cada processo tem um grau de afinidade por processadores, um conjunto de processadores sobre os quais as threads daquele processo podem executar. Este grau de afinidade pode afetar o escalonamento das threads. NT, como Solaris, emprega uma afinidade soft: ele sempre tenta escalonar uma thread sobre o processador no qual ele executou por último. Implementação. Uma thread em NT é uma unidade de execução que inclui um conjunto de instruções, valores relacionados de registradores da CPU e uma pilha. Uma thread executa no espaço de endereçamento de um processo e utiliza os recursos alocados para este processo. Em NT, uma thread deve ter um ID, registradores contendo o estado do processador, duas pilhas (uma para o modo privilegiado do processador e uma em modo usuário) e uma área de armazenamento privado. Threads em NT têm 32 níveis diferentes de prioridade (16 níveis para tempo real, 15 para variável e 1 para o sistema). O escalonador (chamado em [PRA95a] de dispatcher) usa um algoritmo de escalonamento preemptivo baseado em prioridades. A thread com mais alta prioridade é escolhida para executar. Threads podem mudar de prioridade através do procedimento SetThreadPriority. Threads NT podem ser suspensas ou resumidas (ou seja, continuadas após serem suspensas) através de chamadas à SuspendThread e ResumeThread. Uma thread pode ser criada em um estado suspenso. Uma thread pode terminar em uma das seguintes maneiras: • fim do procedimento da rotina associada à thread; • chamada da função ExitThread; • término causado por outra thread chamando procedimento TerminateThread. Quando uma thread termina, o objeto thread torna-se sinalizado: todas as outras threads esperando que aquela thread termine são notificadas. Uma thread em espera pode determinar o status de saída de uma thread terminada através da função GetExitCodeThread. Cada thread tem um único identificador que pode ser recuperado chamando GetCurrentThreadId Em aplicações 32 bits para Windows NT, porém, além do identificador de thread, é necessário um handle do objeto. Identificadores de threads em NT são únicos a nível de sistema. Escalonamento. Como apresentado acima, o escalonador é preemptivo. Quando uma fatia de tempo pré-determinada e específica termina, o escalonador tira o processador da thread que está executando. O escalonador pode escalonar uma outra thread para executar antes do fim da fatia da thread corrente ter terminado em uma das seguintes condições: • a thread em execução chama a função “Sleep”; • a thread se bloqueia chamando uma função que causa que a mesma espere - por exemplo, esperando por um dispositivo de I/O ou esperando por um objeto síncrono ser sinalizado; • uma thread de mais alta prioridade se torna disponível. É importante observar que a unidade de escalonamento é uma thread. O escalonador do NT não escalona processos na maior parte do tempo. As prioridades são controladas nos níveis de processo 27 e de thread. Como citado acima, existem duas grandes classes de prioridade: variáveis e de tempo real. As prioridades variáveis podem ainda ser: • ociosa (Idle); • normal (Normal); • alta (High). Dentro de cada classe, existem sete níveis. Cada thread tem uma prioridade de base que é relativa à classe de prioridade do processo. A preempção é útil para sistemas tais como de tempo real, onde é imperativo para threads de mais alta prioridade estarem prontas para executar. A preempção implica em uma sobrecarga devido ao chaveamento de contexto frequente, de modo que é bastante importante que a próxima tarefa esteja pronta para executar o mais rápido possível. Em sistemas não preemptivos (ou cooperativos) como o Microsoft Windows 3.11, todos os outros processos esperam que o processo em execução libere o processador voluntariamente. No processamento cooperativo, uma aplicação mal desenvolvida pode monopolizar o processador (Figura 8). Preemptivo (Windows NT) Thread 1 Thread 2 Cooperativo (Windows 3.1) Thread 1 Thread 2 Em execução Chaveamento Invonluntário Chaveamento Voluntário Figura 8. Processamento Preemptivo e Cooperativo. O sistema operacional preempta uma thread depois que a fatia de tempo acabou ou quando uma thread de mais alta prioridade se torna pronta para executar. Uma thread voluntariamente libera o processador se ela vai para um estado de espera, completa execução ou se torna uma thread de mais baixa prioridade. Threads preemptadas são colocadas na fila de processos prontos para executar de sua prioridade. Uma regra simples é que as n threads executáveis mais prioritárias estão sempre executando, onde n é o número de processadores. Sincronização. Threads sincronizam-se através de Mutexes Lock (semáforos binários), de objetos de tipo “região crítica” , objetos de evento, objetos de semáforos e operações atômicas sobre inteiros. Estados de threads NT. Uma thread NT pode estar em um dos seguintes estados em um dado tempo: esperando por um evento especificado ocorrer (não pode executar), pronto para executar e esperando por um processador disponível, ou executando em um processador. I/O assíncrono. Windows NT 3.5 suporta um mecanismo chamado portas I/O-completion. Estas portas são projetadas para manipular I/O assíncrono ou sobreposto (concorrente). A função CreateIoCompletionPort associa uma porta com uma coleção de handles de arquivos, e a porta atua como um ponto de sincronização. Quando uma operação de entrada/saída pendente sobre qualquer um dos handles de arquivo completa, um pacote de IO-completion é então enfileirado para aquela porta. Um número de threads de trabalho pode gerenciar entrada/saída para clientes chamando GetQueuedCompletionStatus para esperar sobre a porta do tipo I/O-completion. Estas portas têm controles de concorrência embutidos. O kernel tenta limitar o número de threads executáveis associadas a uma porta, nunca excedendo o valor de concorrência da porta (que é especificada quando a porta é criada). Quando uma thread chama GetQueuedCompletionStatus, ela retorna quando o I/O está disponível. Quando uma das threads associadas com uma completion port está bloqueada, o kernel seleciona uma thread em espera sobre a completion port para executar. Desta maneira, o sistema não fica sobrecarregado com threads executáveis. Threads que bloqueiam em uma completion port são acordadas em uma ordem LIFO (última que chega, primeira que sai), enquanto os pedidos de I/O são tratados em uma ordem FIFO (primeiro que chega, primeiro que sai). Threads em execução - após completar uma transação - pode pegar o próximo pedido sem causar uma mudança de contexto. I/O-completion ports funcionam eficientemente sobre qualquer carga, seu desempenho não sofre com um tráfego pesado. 29 Interface. Windows NT não suporta a interface Posix, e aplicações usam a interface Win32 para desenvolver aplicações multithreaded. Exemplo. Apresentamos a seguir um extrato de um programa que ilustra a criação de uma thread. O procedimento a ser lançado como thread chama-se ThreadFunc. DWORD ThreadFunc (LPDWORD lpdwParam) { printf (“ThreadFunc: thread parameter=%d\n”, *lpdwParam); return 0; } DWORD main (void) { DWORD dwThreadId, dwThrdParam = 1; HANDLE hThread; hThread = CreateThread ( NULL, /* nenhum atributo de segurança */ 0, /* use o tamanho default do stack */ (LPTHREAD_START_ROUTINE) ThreadFunc, /* o procedimento a ser lançado */ &dwThrdParam, /* argumento da função */ 0, /* usar flags default de criação */ &dwThreadId); /* devole o id da thread */ if (hThread == NULL) ErrorExit (“CreateThread error\n”); ... Os atributos de segurança incluem um flag que determina se o handle pode ser herdado ou não por threads filhas. Os atributos de segurança também incluem um descritor de segurança, que o sistema usa para realizar verificações de acesso em todos os usos subsequentes do handle da thread antes que o acesso seja concedido. Por outro lado, um flag de criação permite a criação de uma thread em estado suspenso, com a thread não executando até que a função ResumeThread seja chamada. 3.3 Java Conceitos Básicos. Java é uma linguagem multithreaded, i.e., ela provê suporte para a execução de várias threads que podem tratar diferentes tarefas simultaneamente. O suporte às threads da linguagem Java também inclui um conjunto de primitivas de sincronização. Tais primitivas são baseadas no paradigma de monitores e variáveis de guarda, um esquema amplamente difundido; concebido por [HOA78]. Existem dois mecanismos para a criação de threads em Java: implementando uma interface ou estendendo uma classe. A extensão de uma classe é o mecanismo pelo qual métodos e variáveis são herdados de uma superclasse. A linguagem Java não suporta herança múltipla e portanto uma classe pode estender ou herdar métodos e variáveis de uma única classe. Esta limitação da linguagem Java pode ser tratada através da implementação de interfaces, que é a maneira mais comum de criar threads. As interfaces provêm uma mecanismo pelo qual programadores definem o esqueleto de uma classe, definindo um conjunto de regras de um determinado tipo abstrato. Existem algumas diferenças básicas entre uma classe e uma interface. Primeiramente, uma interface só pode conter métodos abstratos1 ou constantes (variáveis com cláusulas static e final)2. Já uma classe pode implementar métodos e conter variáveis que não sejam constantes. Uma interface não pode implementar métodos. Uma classe que implementa uma interface deve implementar todos os métodos declarados em uma interface. Interfaces têm a habilidade de estender outras interfaces e, diferentemente de classes, podem estender múltiplas interfaces. O primeiro método de criar uma thread é simplesmente estender a classe Thread. Tal procedimento só é recomendado se a classe que deve ser executada com uma thread não precise nunca estender outra classe. A classe Thread é definida no pacote java.lang, que deve ser importado dentro do módulo que contenha uma classe que implemente uma thread. Considere o seguinte exemplo: import java.lang.*; public class Contador extends Thread { public void run() { ... } 1 Um método abstrato define o protótipo de uma função, através da declaração de seu nome, do número e tipo de cada argumento e de seu tipo de retorno. 2 A cláusula static aplicada a uma variável define que somente uma instância desta variável existirá, independente do número de classes instanciadas. A cláusula final impede uma redefinição de uma variável por uma subclasse. Deste modo a linha seguinte define uma constante PI em Java com valor 3,14: static final int PI = 3.14; 31 } Este exemplo cria uma classe Contador que estende a classe Thread do sistema e sobrescreve o método Thread.run() para sua própria implementação. A mesma classe pode ser criada implementando a interface Runnable, como no exemplo a seguir. import java.lang.*; public class Counter implements Runnable { Thread T; public void run() { ... } } Neste exemplo, o método abstrato run() é definido na interface Runnable e está sendo implementado. É importante notar a presença de uma instância de uma classe Thread como uma variável da classe Counter. A única diferença entre os dois métodos é a maior flexibilidade existente na criação de uma classe Counter. No exemplo acima, existe ainda a possibilidade de estender a classe Counter de uma superclasse, se necessário. Deste modo, a maioria das classes criadas como uma thread implementarão a interface Runnable desde que estas provavelmente estarão estendendo funcionalidade de uma alguma outra superclasse. A interface Runnable já existe na linguagem Java. É interessante notar, entretanto, que tal interface não executa trabalho algum, contendo apenas um único método abstrato, como pode ser observado no código a seguir extraído do código fonte da linguagem Java. package java.lang; public interface Runnable { public abstract void run(); } Isso é tudo o que existe na interface Runnable. Uma interface só fornece o esqueleto o qual classes devem implementar. Neste caso, tal interface força a definição de um método run(). A maior parte do trabalho é feita, então, na classe Thread. Abaixo é fornecido o código de uma seção da classe Thread. public class Thread implements Runnable { ... public void run() { if (target != null) { target.run(); } } ... } Do exemplo acima nota-se que a classe Thread também implementa a interface Runnable. O método Thread.run() checa se a classe alvo, i.e., a classe que será executada como uma thread não é igual a null, e então executa o método run() da classe alvo. Quando isto ocorre, o método run() da classe alvo será executado como uma thread própria. Criação de Threads. Considere o exemplo a seguir: class ThreadSimples extends Thread { public ThreadSimples(String str) { super(str); } public void run() { for (int i = 0; i < 10; i++) { System.out.println(i + " " + getName()); try { sleep((int)(Math.random() * 1000)); } catch (InterruptedException e) {} } System.out.println("Terminado!" + getName()); } } class TestaDuasThreads { public static void main (String[] args) { new ThreadSimples("Belo Horizonte").start(); new ThreadSimples("Brasília").start(); } } A primeira classe estende a classe Thread do sistema. O seu primeiro método é um construtor que recebe uma string como argumento e passa este argumento ao construtor de sua superclasse Thread, que usa este argumento mais tarde no programa para imprimir o nome da thread. O método 33 run() imprime o nome da thread corrente dez vezes através da função getName() , dormindo um tempo aleatório entre cada impressão. Ao final, o método run() imprime a palavra “Terminado”. A segunda classe, denominada TestaDuasThreads, provê um método main() que cria duas threads, uma denominada Belo Horizonte e outra denominada Brasília. O método main() também inicializa as duas threads logo após a sua construção através da chamada à função start(). Quando este método é executado, uma saída similar à seguinte é obtida: 0 Belo Horizonte 0 Brasília 1 Brasília 1 Belo Horizonte 2 Belo Horizonte 2 Brasília 3 Brasília 3 Belo Horizonte 4 Belo Horizonte 4 Brasília 5 Belo Horizonte 5 Brasília 6 Brasília 6 Belo Horizonte 7 Belo Horizonte 7 Brasília 8 Brasília 9 Brasília 8 Belo Horizonte Terminado! Brasília 9 Belo Horizonte Terminado! Belo Horizonte É interessante notar que a saída de um thread é intercalada com a saída da outra thread. Isto ocorre porque as duas threads executam simultaneamente. Os dois métodos run() executam simultaneamente, mostrando resultados ao mesmo tempo. É importante que uma thread durma por algum tempo. Se não, esta consumirá todo o tempo de CPU para o processo e não permitirá que outros métodos (outras threads, por exemplo) executem. Inicialização e Interrupção de Threads. O exemplo a seguir, um pouco mais elaborado, descreve os mecanismos de inicialização e interrupção de uma thread, através das funções start() e stop(). import java.applet.*; import java.awt.*; public class ThreadContador extends Applet implements Runnable { Thread t; int Contador; public void init() { Contador=0; t=new Thread(this); t.start(); } public boolean mouseDown(Event e,int x, int y) { t.stop(); return true; } public void run() { while(true) { Contador++; repaint(); try { t.sleep(10); } catch (InterruptedException e) {} } } public void paint(Graphics g) { g.drawString(Integer.toString(Contador),10,10); System.out.println("Contador= "+Contador); } public void stop() { t.stop(); } } Neste exemplo, a classe ThreadContador começa a contar de 0 mostrando o resultado na saída padrão e no console de um browser3. A classe ThreadContador é forçada a implementar a interface Runnable, pois já estende a classe Applet. Numa applet, a execução começa pelo método init(). Neste método, a variável Contador é inicializada e uma nova instância da classe Thread é criada. Depois da criação da thread, a mesma deve ser inicializada. Isso é feito pela chamada à função 3 A classe ThreadContador executa como uma applet, pois é derivada da classe Applet, e como tal é executada no contexto de um browser, como por exemplo o Netscape, que age como o console para a exibição de uma Applet. 35 start(), que então faz uma chamada à função run() da classe alvo da thread. O método run() é um loop infinito, que incrementa a variável Contador, dorme 10 milissegundos, e envia uma requisição para refrescar o console da applet. Para o programa terminar é necessário que o método run() termine, interrompendo deste modo a thread atual. Isso é alcançado pela função stop(), presente no método mouseDown. Deste modo, este programa termina quando o usuário pressiona o mouse com o cursor na região da applet. Suspensão e Retomada de Threads. Uma vez que uma thread é interrompida, esta não pode ser reinicializada com o método start(), já que o método stop() terá terminado a execução da mesma. O método sleep() faz que uma thread durma por um determinador período de tempo e então a execução é retomada quando o tempo limite é alcançado. Entretanto, isso não é ideal, pois em certas condições uma thread deve ser inicializada quando um certo evento ocorre. Neste caso, o método suspend() permite que uma thread tenha sua execução suspensa e o método resume() permite à thread suspensa executar novamente. A applet seguinte modifica o exemplo anterior usando os métodos suspend() e resume(). public class ThreadContador2 extends Applet implements Runnable { Thread t; int Contador; boolean suspensa; public boolean mouseDown(Event e,int x, int y) { if(suspensa) t.resume(); else t.suspend(); suspensa = !suspensa; return true; } ... } Neste exemplo, uma variável booleana é usada para determinar o estado da thread. O pressionamento do mouse pelo usuário suspenderá ou retomará a execução da thread. A distinção de diferentes estados de uma applet é importante porque alguns métodos levantam exceções se estes são chamados num estado errado. Por exemplo, se uma thread foi inicializada e interrompida, a execução do método start() levantará uma exceção IllegalThreadStateException. Escalonamento de Threads. Java tem um escalonador de threads que monitora todas as threads ativas em todos os programas e decide qual thread deve executar e em qual linha de execução. Duas características principais definem uma thread: a sua prioridade e um flag denominado daemon flag. Uma regra básica do escalonador diz que se somente existem daemom threads rodando, a JVM (máquina virtual Java) terminará. Novas threads herdam a prioridade e o daemon flag da thread que foram criadas. O escalonador determina qual thread deve ser executada analisando a prioridade de cada thread. Aquelas com prioridades maiores serão permitidas executar antes do que threads com prioridades mais baixas. O escalonador pode ser preemptivo ou não preemptivo. Escalonadores preemptivos fornecem um certo time-slice para todas as threads que executam no sistema. O escalonador decide qual thread deve executar e então resume() esta thread por um determinado período de tempo. Quando a thread executa por aquele período de tempo, ela é suspended() e a próxima thread escalonada é resumed(). Escalonadores não-preemptivos decidem qual thread deve executar e então executam esta thread até o seu fim. A thread tem controle completo do sistema pelo tempo que ela quiser. O método yields() é um mecanismo pelo qual uma thread força o escalonador a executar uma outra thread que porventura esteja esperando. Dependendo do sistema no qual Java esteja executando, o escalonador pode ser preeemptivo ou não preemptivo. A gama de prioridade de uma thread varia de 1 a 10. A prioridade default de uma thread é Thread.NORM_PRIORITY, que tem o valor 5. Duas outras variáveis estáticas são disponíveis: Thread.MIN_PRIORITY e Thread.MAX_PRIORITY, que tem valores 1 e 10, respectivamente. O método getPriority() retorna a prioridade de uma thread enquanto que o método setPriority() determina a nova prioridade de uma thread. As threads daemon são denominadas threads de serviço que normalmente rodam em baixa prioridade e provem uma mecanismo básico para um programa quando a atividade da máquina está reduzida. Um exemplo de uma daemon thread que está continuamente rodando é o coletor de lixo (garbage collector). Esta thread, fornecida pela JVM, procura por variáveis que nunca serão acessadas novamente e liberam seus recursos para o sistema. Uma thread pode ligar o flag daemon passando uma variável booleana true para o método setDaemon(). Se uma variável booleana false é passada, a thread se tornará uma user thread. Entretanto, isto deve ocorrer antes que esta seja inicializada. 37 Sincronização de Threads. O mecanismo de Threads, como visto até agora, tem um potencial limitado. A sincronização, por outro lado, permite um uso mais efetivo e completo do mecanismo de threads. Toda instância de uma classe em Java tem, potencialmente, um monitor associada a ela. Se a classe não possui funções de sincronização, entretanto, o monitor associado não é efetivamente alocado. Um monitor é simplesmente uma chave que serializa o acesso a um objeto de uma classe. A fim de obter acesso a um objeto, uma thread deve primeiramente alocar o monitor. Isto ocorre automaticamente sempre que se entra em um método sincronizado. Um método sincronizado é criado através da palavra chave synchronized na declaração de um método. Durante a execução de um método sincronizado, a thread mantém para si o monitor daquele objeto de método, ou para o método de classe, se o método é estático. Se uma thread está executando um método sincronizado, uma outra thread que tente acessar este método será bloqueada até que a primeira thread libere o monitor, seja pela finalização da execução do método ou pelo método wait(). A fim de explicitamente obter acesso a um monitor de objeto, uma thread chama um método sincronizado dentro daquele objeto. Para temporariamente liberar o monitor, a thread chama o método wait(). Dado que uma thread deve ter adquirido o monitor do objeto, a chamada a wait() é suportada apenas dentro de métodos sincronizados. O uso de wait() desta forma permite a uma thread rendezvous com alguma outra thread em um ponto de sincronização específico. O exemplo abaixo (problema do produtor e consumidor) fornece maiores detalhes de alguns aspectos de sincronização: class Produtor extends Thread { private Caixa caixa; private int numero; public Produtor(Caixa c, int numero) { caixa = c; this.numero = numero; } public void run() { for (int i = 0; i < 10; i++) { caixa.coloca(i); System.out.println("Produtor #" + this.numero + " coloquei: " + i); try { sleep((int)(Math.random() * 100)); } catch (InterruptedException e) { } } } } class Consumidor extends Thread { private Caixa caixa; private int numero; public Consumidor(Caixa c, int numero) { caixa = c; this.numero = numero; } public void run() { int valor = 0; for (int i = 0; i < 10; i++) { valor = caixa.retira(); System.out.println("Consumidor #" + this.number + " retirei: " + valor); } } } Neste exemplo, o objeto compartilhado é uma classe Caixa, que dispõe de dois métodos principais: coloca() e retira(). A classe Produtor gera inteiros entre 0 e 9, coloca estes inteiros na classe Caixa, e imprime estes valores. O produtor dorme por um período de tempo aleatório antes que um novo número seja produzido. Já a classe Consumidor, mais voraz, retira os elementos da classe Caixa tão logo estes estejam disponíveis. É interessante notar que nem o consumidor nem o produtor possuem código associado à sincronização necessária para este problema. Isto é feito dentro das funções coloca() e retira() da classe caixa. Suponha, entretanto, que não existisse código de sincronização dentro da classe caixa. isto levaria a uma das duas saídas abaixo, ambas erradas, dependendo se o consumidor é mais rápido que o produtor num determinado período de tempo ou vice-versa. Produtor mais rápido: ... Consumidor #1 retirei: 3 Produtor #1 coloquei: 4 Produtor #1 coloquei: 5 Consumidor #1 retirei: 5 ... 39 Consumidor mais rápido: ... Produtor #1 coloquei: 4 Consumidor #1 retirei: 4 Consumidor #1 retirei: 4 Produtor #1 coloquei: 5 ... Tais fatos são provocados por condições de corrida, na qual o consumidor executa assincronamente em relação ao produtor. Deste modo, a classe caixa deve sincronizar a colocação e retirada do número contido nela. O consumidor deve retirar cada número armazenado exatamente uma vez. Isso é atingido através das funções wait() e notify(), como descrito no código abaixo. class Caixa { private int conteudo; private boolean disponivel = false; public synchronized int retira() { while (disponivel == false) { try { wait(); } catch (InterruptedException e) { } } disponivel = false; notify(); return conteudo; } public synchronized void coloca(int valor) { while (disponivel == true) { try { wait(); } catch (InterruptedException e) { } } conteudo = valor; disponivel = true; notify(); } } A caixa tem duas variáveis privadas: a variável (conteudo), que armazena o número produzido e a variável (disponivel), que determina se o número produzido pode ser retirado pelo consumidor. Quando a variável disponivel é verdadeira, o consumidor acabou de colocar um novo valor na caixa e o consumidor ainda não o retirou. O consumidor apenas pode consumir um novo número quando (disponivel = = true). Dado que a classe Caixa tem métodos sincronizados, Java fornece um único monitor para cada instância da classe Caixa (incluindo a classe caixa compartilhada pelas classes Produtor e Consumidor). Sempre que uma thread entra em um método sincronizado, a thread que chamou o método adquire o monitor para aquele objeto do qual o método foi chamado. Outras threads não poderão chamar um método sincronizado até que o monitor seja liberado 4. Assim, sempre que o produtor chama o método coloca(), este adquire o monitor da caixa, impedindo, portanto, que o método retira() seja executado pelo consumidor. Da mesma forma, quando o procedimento retira() é chamado, o monitor é adquirido pelo consumidor impedindo que o método coloca() seja executado pelo produtor. O método notify() “acorda” o consumidor ou o produtor a tentarem exercer suas funções de consumir ou produzir um número. Dependendo da valor da variável disponível, um dois executará o método wait(), dando a chance ao outro de executar. A aquisição e liberação de um monitor é realiza automaticamente pelo sistema, de maneira atômica. Isto garante que condições de corrida não ocorrem nos níveis de implementação das threads, garantindo integridade dos dados. Conclusões e Perspectivas Um processo sempre é uma abstração de um programa sendo executado. O estado de um processo inclui, entre outras informações, o conteúdo de seu espaço de endereçamento, de seus registradores, incluindo um contador de programa e apontador para pilha, e seu estado em relação ao sistema operacional e ao sistema de arquivo - estado de chamadas de sistema e estados de arquivos abertos. Com o advento de multiprocessadores e paralelismo, a abstração de processo se tornou confusa. Dentro de um espaço de endereçamento simples, tem-se várias threads de controle e assim vários contadores de programa e apontadores para pilhas e estados de várias chamadas de sistema realizadas concorrentemente. Esta confusão resultou em diferentes grupos de pesquisa usando diferentes terminologias (algumas vezes até conflitantes) para espaço de endereçamento e threads de controle. Em Mach, o espaço de endereçamento com todas as suas threads é chamada de tarefa 4 É importante frisar que monitores Java são reentrantes, i.e., a mesma thread que detém o controle de um monitor pode chamar um método sincronizado deste objeto, readquirindo o monitor. 41 (task) e as threads de controle são comumente chamadas de threads. Em Topaz, o espaço de endereçamento é chamado de address space e as threads de controle são chamadas de threads - em Topaz, o nome processo é evitado. Em Amoeba, originalmente, um espaço de endereçamento com suas threads é chamado de cluster, enquanto uma thread de controle é chamada de task. Isto levou a uma confusão com a terminologia de Mach, de modo que atualmente o espaço de endereçamento em Amoeba é chamado de processo e uma thread de controle, de thread [MUL93]. Uma discussão conduzida no newsgroup comp.os.research questiona a existência de threads. Alguns projetistas de SO (e.g., aqueles envolvidos no Plan 9 da AT&T e no QNX) argumentam que as threads “resolvem os sintomas, mas não o problema” (sic). Melhor do que usar threads porque o tempo de chaveamento de contexto é alto, uma melhor solução seria “consertar” o próprio sistema operacional. Segundo a discussão, isto é irônico, pois hoje em dia, até sistemas operacionais de computadores pessoais (PC) suportam multiprogramação com auxílio de uma MMU (Memory Management Unit) e, portanto, a programação típica é hoje baseada em espaços comuns de endereçamento compartilhados por threads (ainda que a depuração e o desenvolvimento de códigos seguros sejam mais difíceis). Com um tempo de chaveamento menor, processos pesados podem compartilhar uma área de memória através de um ambiente “threaded”, sem “abrir a caixa de Pandora de problemas que uma memória globalmente compartilhada traz”. Bibliografia de base e Referências Bibliográficas [DRA96] Drake, Donald G. Introduction to JavaThreads - JavaWorld; Abril of 1996 (http://www.javaworld.com). [FLA96] Flanagan, David. Java in a NutShell. O´Reilly &Associates, Inc., USA, 1996. [FR90] Feitelson, D. & Rudolph, L. Distributed Hierarchical Control for Parallel Processing, Vol.23, No. 5, May 1990, pp. 65-77. [HOA78] Hoare, C.A.R. Communicating Sequential Processes. Communications of the ACM, Vol. 21, No. 8, August 1978, pp. 666-677. [JUN96] Junqueira, Bruno de Almeida. Primeiro Trabalho de Sistema Operacional. DCC-UFMG. Trabalho de Disciplina, 1996. [KIT95] Kitajima, João Paulo. Programação Paralela Utilizando Mensagens. XIV JAI, Canela, 1995. [MAN96]McManis, Chuck; Synchronizing threads in Java - JavaWorld, Abril of 1996 (http://www.javaworld.com). [MUL93] Mullender, Sape. Kernel Support for Distributed Systems. In: Distributed Systems, ed. Sape Mullender, Addison-Wesley, ACM Press, 1993. [NWS96] Newsgroup comp.os.research. Frequently Asked Questions, 1996. [PRA95a] Prasad, Shashi. Solaris and Windows NT both support powerful multithreading/multiprocessing to help get the job done faster. Byte, October, 1995. [PRA95b] Prasad, Shashi. To truly reap the rewards of a multiprocessor NT system, you have to use threads. Byte, November, 1995. [SIL94] Silberschatz, A. & Galvin, P. Operating System Concepts. Quarta edição, Addison-Wesley, USA, 1994. [SUN] SUN Microsystems. Java Tutorial (http://www.javasoft.com). [TAN92] Tanenbaum, Andrew. Modern Operating Systems. Prentice-Hall, USA, 1992. [WEI93] Weihl, W. Specifications of Concurrent and Distributed Systems. In: Distributed Systems, ed. Sape Mullender, Addison-Wesley, ACM Press, 1993.