Professional Documents
Culture Documents
1 Definição
A grande maioria dos programas escritos são programas seqüenciais. Nesse caso, existe
somente um fluxo de controle (fluxo de execução, linha de execução, thread) no programa. Isso
permite, por exemplo, que o programador realize uma "execução imaginária" de seu programa
apontando com o dedo, a cada instante, o comando que está sendo executada no momento.
Um programa concorrente pode ser visto como se tivesse vários fluxos de execução.
Para o programador realizar agora uma "execução imaginária", ele vai necessitar de vários dedos,
um para cada fluxo de controle.
O termo "programação concorrente" vem do inglês concurrent programming, onde
concurrent significa "acontecendo ao mesmo tempo". Uma tradução mais adequada seria
programação concomitante. Entretanto, o termo programação concorrente já está solidamente
estabelecido no Brasil. Algumas vezes é usado o termo programação paralela com o mesmo
sentido.
É comum em sistemas multiusuário que um mesmo programa seja executado
simultaneamente por vários usuários. Por exemplo, um editor de texto. Entretanto, ter 10
execuções simultâneas do editor de texto não faz dele um programa concorrente. O que se tem
são 10 processos independentes executando o mesmo programa seqüencial (compartilhando o
mesmo código). Cada processo tem a sua área de dados e ignora a existência das outras
execuções do programa. Esses processos não interagem entre si (não trocam informações). Um
programa é considerado concorrente quando ele (o próprio programa, durante a sua execução)
origina diferentes processos. Esses processos, em geral, irão interagir entre si.
2 Motivação
Arquivo Processo
Leitor
Buffer
Processo Impressora
Impressor física
PC PC PC
Receptor Transmissor
Protocolo
Escritor
Leitor Impressor
O processo "Leitor" executa um laço no qual ele pega um nome de arquivo, envia o
conteúdo do arquivo para o processo "Impressor" e então remove o arquivo lido. O envio do
conteúdo para o processo "Impressor" é feito através de um laço interno composto pela leitura de
uma parte do arquivo e pelo envio dessa parte. Finalmente, o processo "Impressor" é encarregado
de enviar os pedaços de arquivo que ele recebe para a impressora. O relacionamento entre os
processos "Leitor" e "Escritor" foi descrito antes, no início desta seção.
O servidor de impressão ilustra o emprego da programação concorrente na construção de
uma aplicação com paralelismo intrínseco. O resultado é uma organização interna clara e simples
para o programa. Um programa seqüencial equivalente seria certamente menos eficiente. O
restante deste capítulo é dedicado aos problemas e às técnicas existentes para a construção de
programas concorrentes como esse.
Hoje em dia existem várias linguagens que permitem construir programas concorrentes
(Java, Ada, Pascal Concorrente, C estendido com bibliotecas para concorrência, etc.). Aqui será
utilizada uma linguagem apropriada para ensino, denominada Vale4 (V4) [TOS04].
3 Especificação do paralelismo
Para construir um programa concorrente, antes de mais nada, é necessário ter a capacidade de
especificar o paralelismo dentro do programa. Essa especificação pode ser feita de diversas
maneiras. Uma delas utiliza os comandos fork, quit e join [CON63]. Outras maneiras serão
apresentadas na seção 4.
No sistema operacional Unix a operação fork cria um novo processo que é uma cópia idêntica
(clone) do processo que executa esta operação. Por exemplo, o comando
id = fork()
cria um filho idêntico ao processo que executou a operação (isto é, cria uma cópia do processo
original). O processo filho recebe cópias das variáveis do processo pai, bem como dos descritores
de arquivos. Os valores iniciais das variáveis do filho são iguais aos valores das variáveis
correspondentes do pai no momento da execução da função fork. Observe que não há
compartilhamento de variáveis: as variáveis do pai ficam no espaço de endereçamento1 do pai e
as variáveis do filho, no espaço de endereçamento do filho. Em relação aos valores dessas
variáveis, a única diferença inicial entre o pai e o filho é o valor da variável id, que é 0 para o
filho e é o valor de retorno da função fork para o pai. O valor de retorno é o número de
identificação do processo criado (process identification ou pid). Isto permite que os processos
prossigam de acordo com suas identidades. Normalmente, o comando seguinte ao fork tem a
seguinte forma:
if id = 0
then { processamento do filho }
else { processamento do pai }
Um dos processos, por exemplo o filho, pode “sobrepor” um novo código sobre si,
através da operação exec(P), onde P é um novo programa para ser executado (novo segmento de
código e de dados para o processo).
Na linguagem Vale4, o funcionamento do comando fork é similar ao do Unix, porém com uma
grande diferença: o fork cria uma thread e não um processo. As threads mãe e filha são idênticas:
executam o mesmo código e compartilham todas as variáveis do processo em que são definidas
(é justamente nesse compartilhamento que está a diferença para o Unix).
Na linguagem V4, a execução do comando
id := fork()
1
Basicamente, o espaço de endereçamento de um processo é formado por um segmento de código e um segmento
de dados. O primeiro contém as instruções do processo e o segundo contém as variáveis e constantes do processo.
Além disso, todo processo possui uma pilha que é usada para chamadas e retornos de procedimentos.
PROGRAMAÇÃO CONCORRENTE – Prof. Simão Toscani 5
(Do livro Sistemas Operacionais e Programação Concorrente, Toscani S.S., Oliveira R.S. e Carissimi A.S.
Editora Sagra-Luzzatto, 2003)
faz com que a variável global2 id receba o valor retornado pela função fork, que é o número
único da thread criada. A thread original (mãe) e a thread criada (filha) executam em paralelo a
partir do comando que segue ao fork. As duas threads irão se distinguir através da variável
denominada myNumber3. O valor dessa variável é sempre a identificação de quem a refere (isto é,
o número único de quem a refere). Tipicamente, o comando que segue ao fork tem a seguinte
forma:
if id = myNumber
then { processamento da filha }
else { processamento da mãe }
Observe que apenas para a thread filha o myNumber é igual ao valor da variável global
id.
Para criar processos (não threads) dinamicamente, a linguagem V4 oferece o comando
new que será apresentado na seção que segue. Enquanto o comando fork cria uma nova thread, o
comando new cria um novo processo.
O comando join(id) permite que um processo (ou thread) P espere pelo término do
descendente imediato (filho ou filha) identificado por id. Se o argumento id não identifica um
processo ou thread, ou se id não corresponde a um descendente imediato de P, então um erro é
reportado (erro de execução).
O argumento de join pode ser também a palavra reservada any, que significa o desejo de
esperar pelo término de qualquer um dos descendentes imediatos. Nesse caso, no retorno da
primitiva, a variável any4 vai conter a identidade do filho ou filha cuja execução terminou.
Outra peculiaridade da primitiva join(id) é que ela pode ser usada como função ou como
subrotina. Usada como função, ela retorna o valor que o filho (ou filha) id especificou no
comando quit, ao morrer, conforme é explicado a seguir.
O último comando do conjunto é o quit, que mata o seu executor. Podem ser usadas as
formas quit ou quit(X). No segundo caso, o argumento X é um valor inteiro que o processo ou
thread informa ao seu genitor, ao morrer. Se desejar pegar esse valor, o genitor usará a primitiva
join, como função.5 A propósito, um quit sem argumento equivale a um quit(0).
2
Variável declarada no processo hospedeiro, compartilhada entre mãe e filha.
3
Tudo se passa como se cada processo ou thread tivesse uma variável local, denominada myNumber, contendo o
número único desse processo ou thread. Na verdade, myNumber, que também pode ser referido como myself ou
myID, é uma função primitiva que consulta o registro descritor do processo ou thread para obter o número de sua
“carteira de identidade” e retorna esse número.
4
Semelhantemente ao que ocorre com myNumber, tudo se passa como se cada processo ou thread tivesse uma
variável local, denominada any.
5
No caso de ser usada como subrotina, a primitiva join(id) desconsidera o valor informado pelo descendente id.
PROGRAMAÇÃO CONCORRENTE – Prof. Simão Toscani 6
(Do livro Sistemas Operacionais e Programação Concorrente, Toscani S.S., Oliveira R.S. e Carissimi A.S.
Editora Sagra-Luzzatto, 2003)
No caso de um processo com N threads, tem-se um único registro descritor (o registro do
processo hospedeiro) e N mini-descritores de threads. O mini-descritor de cada thread é usado
para salvar os valores dos registradores da UCP (PC, PSW, etc.). Adicionalmente, cada thread
possui uma pilha, que é usada para as chamadas e retornos de procedimentos.
É mais fácil chavear a execução entre threads (de um mesmo processo) do que entre
processos, pois tem-se menos informações para salvar e restaurar. Por esse motivo, as threads são
chamadas também de "processos leves".
Um exemplo
Vamos usar a linguagem V4 para exemplificar o uso dos comandos fork, join e quit. O programa
mostrado na figura 5 possui um único processo. A linha de execução inicial desse processo
executa duas vezes o comando fork, criando duas threads adicionais (filhas 1 e 2). A thread
original então espera que cada uma das filhas termine, escreve uma mensagem para cada uma e
termina também. As duas threads criadas apenas colocam uma mensagem na tela e terminam.
V4program
process p1;
f1: integer; /* identifica filha 1*/
f2: integer; /* identifica filha 2*/
join(f1);
write('Filha 1 morreu'); nl;
join(f2);
write('Filha 2 morreu'); nl
}
end program
O comando write(X) escreve na tela do terminal do usuário. O argumento X pode ser uma
constante, uma variável, um elemento de array ou um string entre apóstrofes. O comando nl
(“new line”) faz com que a próxima impressão seja feita em uma nova linha. Duas possíveis
saídas para o programa anterior seriam:
Criação estática
V4program
process P1;
k: integer init 0;
while k < 10 do
{ write(1);
k:=k+1
};
process P2;
k: integer init 0;
while k < 10 do
{ write(2);
k:=k+1
}
endprogram
6
Por exemplo, se a execução não “passa” por uma determinada instrução fork, então a thread correspondente não é
criada.
7
A declaração explícita consiste em definir, para cada processo do programa, as suas variáveis locais e o seu
segmento de código.
8
Combinações de uma seqüência de 20 escaninhos, escolhidos 10 a 10.
PROGRAMAÇÃO CONCORRENTE – Prof. Simão Toscani 8
(Do livro Sistemas Operacionais e Programação Concorrente, Toscani S.S., Oliveira R.S. e Carissimi A.S.
Editora Sagra-Luzzatto, 2003)
Array de processos
Neste caso, uma única declaração especifica um grupo de processos semelhantes, que se
distinguem apenas pelo valor de uma variável local inteira, que é especificada no cabeçalho do
processo, conforme é ilustrado a seguir, considerando o mesmo exemplo anterior.
V4program
process P (i := 1 to 2);
k: integer init 0;
while k < 10 do
{ write(i);
k:=k+1
}
endprogram
Para o primeiro processo, a sua variável local i vale 1 e para o segundo, a sua variável
local i vale 2. Este programa é equivalente ao anterior.
Criação dinâmica
É possível declarar explicitamente um modelo (uma "forma") para criar processos durante a
execução, conforme é ilustrado a seguir. Neste caso tem-se o que se denomina criação dinâmica
com declaração explícita de processos.
V4program
process type P (i: integer);
k: integer init 0;
while k < 10 do
{ write(i);
k:=k+1
};
process Q;
{ new P(1); new P(2) }
endprogram
Esta seção apresenta programas simples que ilustram características importantes dos programas
concorrentes. Tratam-se de programas Vale4 completos, prontos para serem compilados e
Compartilhamento de um procedimento
O mesmo programa que foi utilizado na seção 3.5 para distinguir as diferentes maneiras de
especificar (e criar) processos é reescrito para ilustrar o compartilhamento de um procedimento.
V4program
procedure imprime(i: integer);
k: integer init 0;
while k < 10 do
{ write(i);
k:=k+1
};
process P1; imprime(1);
process P2; imprime(2)
endprogram
Cada processo chama imprime fornecendo como argumento o número a ser impresso.
Como nos exemplos anteriores, o resultado da execução desse programa é imprevisível, sendo
10
possíveis (teoricamente) C 20 resultados distintos.
V4program
S : integer init 0;
process p1;
PROGRAMAÇÃO CONCORRENTE – Prof. Simão Toscani 10
(Do livro Sistemas Operacionais e Programação Concorrente, Toscani S.S., Oliveira R.S. e Carissimi A.S.
Editora Sagra-Luzzatto, 2003)
k: integer init 0;
{ loop
S:= S+1;
k:= k+1;
exit when k = 100
endloop;
nl; write('p1'); tab(2); write(S)
};
process p2;
k: integer init 0;
{ loop
S:= S+1;
k:= k+1;
exit when k = 100
endloop;
nl; write('p2'); tab(2); write(S)
}
endprogram
Este programa utiliza os comandos loop e tab(K), os quais são explicados a seguir.
O comando loop
O comando loop implementa um “ciclo infinito”. No seu interior, o comando “exit when
<cond>” significa que o ciclo acaba (isto é, que a execução vai para o comando seguinte ao
endloop) quando a condição <cond> é verdadeira. Este comando pode substituir todos os demais
comandos iterativos (while, repeat, for, etc.), com a vantagem de, em geral, tornar os programas
mais claros.
O comando tab(K)
Este comando escreve K espaços em branco no terminal do usuário, sendo útil para formatar a
impressão de resultados.
Observação sobre a inicialização de variáveis
Em V4, as variáveis não explicitamente inicializadas possuem valor inicial igual a 0, se inteiras,
e igual a false, se booleanas. Quando usada, a cláusula initial (ou init) deve ser seguida de um
único valor inicial. Se o uso é na declaração de uma lista de identificadores, então todos os
identificadores da lista recebem esse único valor inicial. Se o uso é na declaração de um array,
todos os elementos do array recebem esse único valor especificado.
Como os pontos em que a UCP passa de um processo para outro são imprevisíveis, pode
acontecer de um processo perder a UCP no meio da seqüência acima. 9 Vamos supor que o valor
de S carregado na pilha seja 10 e que o processo perca a UCP. Nesse caso, quando este processo
receber a UCP de volta, ele vai concluir a seqüência acima e armazenar o valor 11 em S. Todos
os acréscimos a S feitos pelo outro processo nesse ínterim são perdidos. Como conseqüência,
embora S inicie com o valor zero e cada processo some 1 a S cem vezes, o valor final de S
dificilmente será igual a 200. Para o resultado ser 200, deve haver exclusão mútua no acesso à
variável S, isto é, enquanto um processo estiver manipulando S, o outro não pode acessar S.
A exclusão mútua é um requisito muito importante nos sistemas concorrentes. Em geral,
é necessário garantir o acesso exclusivo aos dados compartilhados. A exclusão mútua só não
é necessária quando os dados são compartilhados na modalidade "apenas leitura", isto é, quando
os dados não são alterados pelos processos. Os trechos dos processos onde os dados
compartilhados são manipulados, são denominados trechos críticos ou regiões críticas.
V4program
process type Hanoi(n, a, b, c: integer);
id, m: integer;
if n = 1
then { nl; write(a); write(' --> '); write(b) }
else { m:= n-1;
id:= new Hanoi(m, a, c, b); join(id);
nl; write(a); write(' --> '); write(b);
id:= new Hanoi(m, c, b, a); join(id)
};
9
Sempre que um processo perde a UCP, o seu estado é salvo. Mais tarde, a execução do processo continua, como
se nada tivesse acontecido.
PROGRAMAÇÃO CONCORRENTE – Prof. Simão Toscani 12
(Do livro Sistemas Operacionais e Programação Concorrente, Toscani S.S., Oliveira R.S. e Carissimi A.S.
Editora Sagra-Luzzatto, 2003)
process P; new Hanoi(3, 1, 2, 3)
endprogram
O resultado da execução deste programa será a ordem em que os discos deverão ser
movimentados (movimento por movimento, um disco por vez).
6 Sincronizações básicas
É comum um processo ter que esperar até que uma condição se torne verdadeira. Para essa espera
ser "eficiente", o processo deve esperar no estado bloqueado (sem competir pela UCP). Dois
tipos de bloqueio são considerados básicos:
lock; . . . ; unlock
Um processo só entra num trecho delimitado pelo par lock/unlock se nenhum outro processo está
executando em um outro trecho delimitado dessa maneira. Isto é, o primeiro processo que
executa o comando lock passa e tranca a passagem (chaveia a fechadura) para os demais. O
comando unlock deixa passar (desbloqueia) o primeiro processo da fila de processos que estão
bloqueados por terem executado um lock (enquanto a fechadura estava trancada). Se a fila está
vazia, a fechadura é destrancada (isto é, é deixada aberta).
block/wakeup(P)
Quando um processo P executa o comando block, ele se bloqueia até que um outro processo
execute o comando wakeup(P). Este último comando acorda (desbloqueia) o processo
especificado por P. Se wakeup(P) é executado antes, o processo P não se bloqueia ao executar o
block. Na linguagem Vale4, o argumento de wakeup pode ser um nome de processo ou um
número único de processo ou thread.
Na verdade, os comandos lock e unlock não formam uma estrutura sintática (isto é, não
precisam estar casados, como se fossem um abre e fecha parênteses), eles são comandos
independentes. Na linguagem Vale4, as operações lock e unlock também podem ser referidas
pelos nomes mutexbegin e mutexend, respectivamente.
7 Semáforos
As variáveis semáforas são variáveis especiais que admitem apenas duas operações, denominadas
P e V.11 Sendo S é uma variável semáfora, as operações P e V têm a seguinte semântica:
10
O recurso pode ser, inclusive, "o direito de acessar os dados compartilhados".
11
P e V são as iniciais das palavras holandesas Proberen e Verhogen, que significam testar e incrementar,
respectivamente.
PROGRAMAÇÃO CONCORRENTE – Prof. Simão Toscani 13
(Do livro Sistemas Operacionais e Programação Concorrente, Toscani S.S., Oliveira R.S. e Carissimi A.S.
Editora Sagra-Luzzatto, 2003)
• V(S) : incrementa S de 1.
X : semaphore initial 1
Y : semaphore initial 0
P1: P2:
... ...
P(Y); % block ...
B; A;
... V(Y); % wakeup(P1)
... ...
Esta seção apresenta 4 problemas clássicos da programação concorrente, todos resolvidos através
do uso de semáforos e todos escritos de acordo com a sintaxe de Vale4.
Este problema pode ser enunciado como segue. Um par de processos compartilha um buffer de N
posições. O primeiro processo, denominado produtor, passa a vida a produzir mensagens e a
colocá-las no buffer. O segundo processo, denominado consumidor, passa a vida a retirar
mensagens do buffer (na mesma ordem em que elas foram colocadas) e a consumí-las.
A relação produtor-consumidor ocorre comumente em sistemas concorrentes e o
problema se resume em administrar o buffer que tem tamanho limitado. Se o buffer está cheio, o
produtor deve se bloquear, se o buffer está vazio, o consumidor deve se bloquear. A programação
desse sistema com buffer de 5 posições e supondo que as mensagens sejam números inteiros, é
mostrada a seguir.
O semáforo cheios conta o número de buffers cheios e o semáforo vazios conta número de
buffers vazios. Conforme já foi referido, as variáveis inteiras que não são inicializadas tem seu
valor inicial igual a zero.
Observe que a solução não se preocupou em garantir exclusão mútua no acesso ao buffer.
Isto porque os dois processos trabalham com variáveis locais in, out e msg e, certamente, irão
acessar sempre posições diferentes do vetor global buffer.
Este problema ilustra as situações de deadlock e de postergação indefinida que podem ocorrer em
sistemas nos quais processos adquirem e liberam recursos continuamente.
Existem N filósofos que passam suas vidas pensando e comendo. Cada um possui seu
lugar numa mesa circular, em cujo centro há um grande prato de spaghetti. A figura 6 ilustra a
situação para 5 filósofos. Como a massa é muito escorregadia, ela requer dois garfos para ser
PROGRAMAÇÃO CONCORRENTE – Prof. Simão Toscani 15
(Do livro Sistemas Operacionais e Programação Concorrente, Toscani S.S., Oliveira R.S. e Carissimi A.S.
Editora Sagra-Luzzatto, 2003)
comida. Na mesa existem N garfos, um entre cada dois filósofos, e os únicos garfos que um
filósofo pode usar são os dois que lhe correspondem (o da sua esquerda e o da sua direita). O
problema consiste em simular o comportamento dos filósofos procurando evitar situações de
deadlock (bloqueio permanente) e de postergação indefinida (bloqueio por tempo indefinido).
filósofo 3
garfo 3 garfo 2
filósofo 4 filósofo 2
spaghetti
garfo 4 garfo 1
filósofo 5 filósofo 1
garfo 5
Da definição do problema, tem-se que nunca dois filósofos adjacentes poderão comer ao
mesmo tempo e que, no máximo, N/2 filósofos poderão estar comendo de cada vez. Na solução a
seguir, iremos nos concentrar no caso de 5 filósofos. Os garfos são representados por um vetor de
semáforos e é adotada a seguinte regra: todos os 5 filósofos pegam primeiro o seu garfo da
esquerda, depois o da direita, com exceção de um deles, que é do contra. Pode ser demonstrado
que esta solução é livre de deadlocks. Foi escolhido o filósofo 1 para ser do contra.
O programa a seguir é um programa Vale4 completo, no qual cada filósofo faz 10
refeições e morre.
V4program
garfo: array[5] of semaphore init 1; % array global
procedure getForks(i: integer);
j: integer;
{ j := i-1 ; % j é o garfo da esquerda
if j = 0 then { P(garfo[1]); P(garfo[5]) }
else { P(garfo[j]); P(garfo[i]) }
};
procedure putForks(i: integer);
j: integer;
{ j := i-1 ; % j é o garfo da esquerda
if j = 0 then { V(garfo[1]); V(garfo[5]) }
else { V(garfo[j]); V(garfo[i]) }
};
Barbeiro dorminhoco
Quando termina o corte de cabelo, o cliente deixa a barbearia e o barbeiro repete o seu
loop onde tenta pegar um próximo cliente. Se tem cliente, o barbeiro faz outro corte. Se não tem,
o barbeiro dorme.
O problema dos readers and writers [COU71] ilustra outra situação comum em sistemas de
processos concorrentes. Este problema surge quando processos executam operações de leitura e
de atualização sobre um arquivo global (ou sobre uma estrutura de dados global). A
sincronização deve ser tal que vários readers (isto é, processos leitores, que não alteram a
informação) possam utilizar o arquivo simultaneamente. Entretanto, qualquer processo writer
deve ter acesso exclusivo ao arquivo.
Na solução a seguir é dada prioridade para os processos readers. São utilizadas duas
variáveis semáforas, mutex e w, para exclusão mútua, e uma variável inteira nr, para contar o
número de processos leitores ativos. Note que o primeiro reader bloqueia o progresso dos writers
que chegam após ele, através do semáforo w. Enquanto houver reader ativo, os writers ficarão
bloqueados.
Processo leitor:
...
P(mutex); Processo escritor:
nr:=nr+1; ...
if nr=1 then P(w); ...
V(mutex); P(w);
READ WRITE
P(mutex); V(w);
nr:=nr-1; ...
if nr=0 then V(w); ...
V(mutex);
...
1. Um semáforo binário é um semáforo cujo valor pode ser 0 ou 1. Mostre como um semáforo
geral pode ser implementado a partir de semáforos binários.
3. Usando semáforos, complete o programa Vale4 a seguir, de maneira que o resultado impresso
seja sempre "AAAAABBBBB" (isto é, cada processo só pode imprimir 'B' depois de todos os
outros terem impresso 'A').
V4program
...
process P (i := 1 to 5);
...
{ ...
write('A');
...
write('B');
...
}
endprogram
I
p1 p3
p2
p5
p4
p6