Threads - Angelfire

Propaganda
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.
Download