Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”)...

42
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

Transcript of Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”)...

Page 1: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

1

Introdução aos Processos Leves (“Threads”)

João Paulo F. W. KitajimaMarco Aurélio de Souza Mendes

Departamento de Ciência da ComputaçãoUniversidade Federal de Minas GeraisCaixa Postal 70230161-970 - Belo Horizonte, MGtel: (031) 499 58 60/fax: (031) 499 58 58e-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

Page 2: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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 tornam-

se 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].

Page 3: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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.

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,

Hardware do Computador

Sistema Operacional

Programas de Aplicação

Usuário 1 Usuário 2 Usuário 3 Usuário N

Page 4: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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;

Page 5: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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.

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

Running

Blocked Ready

Page 6: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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

Page 7: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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):

Page 8: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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.

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,

Pilha

BSS

Código

Dados

Page 9: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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.

Page 10: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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

Page 11: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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 Processo 2counter :=2; counter := 0;counter := counter+1; 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íveisdiferentes 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:

Page 12: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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.

Page 13: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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

Page 14: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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.

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:

Processos PesadosContador dePrograma

Thread

Page 15: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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

Page 16: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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

Page 17: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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

Page 18: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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.

Page 19: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

19

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/O-

Bound, 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

User Space

Kernel SpaceKernel Kernel

Run time system

Threads Threads

User-level threads package Kernel-level threads package

Page 20: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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

Page 21: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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

Page 22: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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.

CPU

Kernel

Kernel Thread

Lightweight Process

Page 23: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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> /* inclusão da biblioteca thread.h */#include <synch.h>#include <stdlib.h>#define MAXTAM 10

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);

Page 24: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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).

Page 25: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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

Page 26: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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

Page 27: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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).

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

Thread 2

Thread 1

Preemptivo (Windows NT)

Thread 2

Thread 1

Cooperativo (Windows 3.1)

Em execução

Chaveamento Invonluntário

Chaveamento Voluntário

Page 28: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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.

Page 29: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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

Page 30: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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 umasubclasse. Deste modo a linha seguinte define uma constante PI em Java com valor 3,14:static final int PI = 3.14;

Page 31: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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.

Page 32: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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

Page 33: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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.*;

Page 34: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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.

Page 35: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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.

Page 36: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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.

Page 37: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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) { }

Page 38: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

} }}

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

. . .

Page 39: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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

Page 40: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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 liberado4.

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 monitorpode chamar um método sincronizado deste objeto, readquirindo o monitor.

Page 41: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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”.

Page 42: Introdução aos Processos Leves (“ Threads · 1 Introdução aos Processos Leves (“Threads”) João Paulo F. W. Kitajima Marco Aurélio de Souza Mendes Departamento de Ciência

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/multipro-

cessing 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.