O segundo exercício do projeto pretende construir um sistema simples de publicação e subscrição de mensagens, que são armazenadas no sistema de ficheiros TecnicoFS. O sistema vai ter um processo servidor autónomo, ao qual diferentes processos clientes se podem ligar, para publicar ou receber mensagens numa dada caixa de armazenamento de mensagens.
Para resolver o segundo exercício, os grupos devem usar como base a sua solução do 1º exercício ou aceder ao novo código base, que estende a versão original do TecnicoFS das seguintes maneiras:
- As operações principais do TecnicoFS estão sincronizadas usando um único trinco (mutex) global. Embora menos paralela que a solução pretendida para o primeiro exercício, esta solução de sincronização é suficiente para implementar os novos requisitos;
- É implementado a operação
tfs_unlink
, que permite remover ficheiros.
Adicionalmente, o código base inclui esqueletos para:
- O programa do servidor mbroker (na diretoria
mbroker
); - A implementação do cliente para publicação (na diretoria
publisher
); - A implementação do cliente para subscrição (na diretoria
subscriber
); - A implementação do cliente de gestão (directoria
manager
).
Em vez do novo código base, os grupos que tenham uma solução robusta no 1º exercício são encorajados a construírem a solução com base na sua versão, que à partida estará mais otimizada em termos de concorrência.
O sistema é formado pelo servidor (mbroker) e por vários publicadores (publishers), subscritores (subscribers) e gestores (manager).
Um conceito fundamental do sistema são as caixas de mensagens. Cada caixa pode ter um publicador e múltiplos subscritores. O publisher coloca mensagens na caixa, e os vários subscribers lêem as mensagens da caixa. Cada caixa é suportada no servidor por um ficheiro no TFS. Por esta razão, o ciclo de vida de uma caixa é distinto do ciclo de vida do publisher que lá publica mensagens. Aliás, é possível que uma caixa venha a ter vários publishers ao longo da sua existência, embora apenas um de cada vez.
As operações de criação e remoção de caixa são geridas pelo manager. Adicionalmente, o manager permite listar as caixas existentes na mbroker.
O servidor incorpora o TecnicoFS e é um processo autónomo, inicializado da seguinte forma:
$ mbroker <pipename> <max_sessions>
O servidor cria um named pipe cujo nome (pipename) é o indicado no argumento acima. É através deste named pipe, criado pelo servidor, que os processos cliente se poderão ligar para se registarem.
Qualquer processo cliente pode ligar-se ao named pipe do servidor e enviar-lhe uma mensagem a solicitar o início de uma sessão. Uma sessão consiste em ter um named pipe do cliente, onde o cliente envia as mensagens (se for um publicador) ou onde o cliente recebe mensagens (se for um subscritor). Um dado cliente apenas assume um dos dois papéis, ou seja, ou é exclusivamente publicador e só envia informação para o servidor, ou é exclusivamente subscritor (ou gestor) e só recebe informação.
O named pipe da sessão deve ser criado previamente pelo cliente. Na mensagem de registo, o cliente envia o nome do named pipe a usar durante a sessão.
Uma sessão mantém-se aberta até que aconteça uma das seguintes situações:
- Um cliente (publicador ou subscritor) feche o seu named pipe, sinalizando implicitamente o fim de sessão;
- A caixa é removida pelo gestor.
O servidor aceita um número máximo de sessões em simultâneo, definido pelo valor do argumento max_sessions
.
Nas subsecções seguintes descrevemos o protocolo cliente-servidor em maior detalhe, i.e., o conteúdo das mensagens de pedido e resposta trocadas entre clientes e servidor.
O servidor deve ter uma thread para gerir o named pipe de registo e lançar max_sessions
threads para processar sessões.
Quando chega um novo pedido de registo, este deve ser enviado para uma thread que se encontre disponível, que irá processá-lo durante o tempo necessário.
Para gerir estes pedidos, evitando que as threads fiquem em espera ativa, a main thread e as worker threads cooperam utilizando uma fila produtor-consumidor, segundo a interface disponibilizada no ficheiro producer-consumer.h
.
Desta forma, quando chega um novo pedido de registo, este é colocado na fila e assim que uma thread fique disponível, irá consumir e tratar esse pedido.
A arquitetura do servidor está sumarizada na seguinte figura:
- O mbroker usa o TFS para armazenar as mensagens das caixas;
- A main thread recebe pedidos através do register pipe e coloca-os numa fila de produtor-consumidor;
- As worker threads executam os pedidos dos clientes, dedicando-se a atender um cliente de cada vez;
- Cooperam com a main thread através de uma fila produtor-consumidor, que evita espera ativa.
Um publicador é um processo lançado da seguinte forma:
pub <register_pipe> <pipe_name> <box_name>
Assim que é lançado, o publisher, pede para iniciar uma sessão no servidor de mbroker, indicando a caixa de mensagens para a qual pretende escrever mensagens.
Se a ligação for aceite (pode ser rejeitada caso já haja um publisher ligado à caixa, por exemplo) fica a receber mensagens do stdin
e depois publica-as.
Uma mensagem corresponde a uma linha do stdin
, sendo truncada a um dado valor máximo e delimitada por um \0
, como uma string de C.
A mensagem não deve incluir um \n
final.
Se o publisher receber um EOF (End Of File, por exemplo, com um Ctrl-D), deve encerrar a sessão fechando o named pipe.
O nome do named pipe da sessão é escolhido automaticamente pelo publisher, de forma a garantir que não existem conflitos com outros clientes concorrentes. O named pipe deve ser removido do sistema de ficheiros após o fim da sessão.
Um subscritor é um processo lançado da seguinte forma:
sub <register_pipe> <pipe_name> <box_name>
Assim que é lançado, o subscriber:
- Liga-se à mbroker, indicando qual a caixa de mensagens que pretende subscrever;
- Recolhe as mensagens já aí armazenadas e imprime-as uma a uma no
stdout
, delimitadas por\n
; - Fica à escuta de novas mensagens;
- Imprime novas mensagens quando são escritas para o named pipe para o qual tem uma sessão aberta.
Para terminar o subscriber, este deve processar adequadamente o SIGINT
(i.e., o Ctrl-C), fechando a sessão e imprimindo no stdout
o número de mensagens recebidas durante a sessão.
O nome do named pipe da sessão é escolhido automaticamente pelo subscriber, de forma a garantir que não existem conflitos com outros clientes concorrentes. O named pipe deve ser removido do sistema de ficheiros após o fim da sessão.
Um gestor é um processo lançado de uma das seguintes formas:
manager <register_pipe> <pipe_name> create <box_name>
manager <register_pipe> <pipe_name> remove <box_name>
manager <register_pipe> <pipe_name> list
Assim que é lançado, o manager:
- Envia o pedido à mbroker;
- Recebe a resposta no named pipe criado pelo próprio manager;
- Imprime a resposta e termina.
O nome do named pipe da sessão é escolhido automaticamente pelo manager, de forma a garantir que não existem conflitos com outros clientes concorrentes. O named pipe deve ser removido do sistema de ficheiros antes do manager terminar.
Um primeiro exemplo considera o funcionamento sequencial dos clientes:
- Um manager cria a caixa
bla
; - Um publisher liga-se à mesma caixa, escreve 3 mensagens e desliga-se;
- Um subscriber liga-se à mesma caixa e começa a receber mensagens;
- Recebe as três, uma de cada vez, e depois fica à espera de mais mensagens.
Num segundo exemplo, mais interessante, vai existir concorrência entre clientes:
- Um publisher liga-se;
- Entretanto, um subscriber para a mesma caixa, liga-se também;
- O publisher coloca mensagens na caixa e estas vão sendo entregues imediatamente ao subscriber, ficando à mesma registadas no ficheiro;
- Um outro subscriber liga-se à mesma caixa, e começa a receber as mensagens todas desde o início da sua subscrição;
- Agora, quando o publisher escreve uma nova mensagem, ambos os subscriber recebem a mensagem diretamente.
Para moderar a interação entre o servidor e os clientes, é estabelecido um protocolo, que define como é que as mensagens são serializadas, ou seja, como é que ficam arrumadas num buffer de bytes. Este tipo de protocolo é por vezes referido como um wire protocol, numa alusão aos dados que efetivamente circulam no meio de transmissão, que neste caso, serão os named pipes.
O conteúdo de cada mensagem deve seguir o seguinte formato, onde:
- O símbolo
|
denota a concatenação de elementos numa mensagem; - Todas as mensagens de pedido são iniciadas por um código que identifica a operação solicitada (
OP_CODE
); - As strings que transportam os nomes de named pipes são de tamanho fixo, indicado na mensagem.
No caso de nomes de tamanho inferior, os caracteres adicionais devem ser preenchidos com
\0
.
O named pipe do servidor, que só recebe registos de novos clientes, deve receber mensagens do seguinte tipo:
Pedido de registo de publisher:
[ code = 1 (uint8_t) ] | [ client_named_pipe_path (char[256]) ] | [ box_name (char[32]) ]
Pedido de registo de subscriber:
[ code = 2 (uint8_t) ] | [ client_named_pipe_path (char[256]) ] | [ box_name (char[32]) ]
Pedido de criação de caixa:
[ code = 3 (uint8_t) ] | [ client_named_pipe_path (char[256]) ] | [ box_name (char[32]) ]
Resposta ao pedido de criação de caixa:
[ code = 4 (uint8_t) ] | [ return_code (int32_t) ] | [ error message (char[1024]) ]
O return code deve ser 0
se a caixa foi criada com sucesso, e -1
em caso de erro.
Em caso de erro a mensagem de erro é enviada (caso contrário, fica simplesmente inicializada com \0
).
Pedido de remoção de caixa:
[ code = 5 (uint8_t) ] | [ client_named_pipe_path (char[256]) ] | [ box_name (char[32]) ]
Resposta ao pedido de remoção de caixa:
[ code = 6 (uint8_t) ] | [ return_code (int32_t) ] | [ error message (char[1024]) ]
Pedido de listagem de caixas:
[ code = 7 (uint8_t) ] | [ client_named_pipe_path (char[256]) ]
A resposta à listagem de caixas vem em várias mensagens, do seguinte tipo:
[ code = 8 (uint8_t) ] | [ last (uint8_t) ] | [ box_name (char[32]) ] | [ box_size (uint64_t) ] | [ n_publishers (uint64_t) ] | [ n_subscribers (uint64_t) ]
O byte last
é 1
se esta for a última caixa da listagem e a 0
em caso contrário.
box_size
é o tamanho (em bytes) da caixa, com n_publisher
(0
ou 1
) indicando se existe um publisher ligado à caixa naquele momento, e n_subscriber
o número de subscritores da caixa naquele momento.
O publisher envia mensagens para o servidor do tipo:
[ code = 9 (uint8_t) ] | [ message (char[1024]) ]
O servidor envia mensagens para o subscriber do tipo:
[ code = 10 (uint8_t) ] | [ message (char[1024]) ]
Quando o servidor inicia, lança um conjunto de S
tarefas (thread pool), que ficam à espera de pedidos de registo para tratar, que irão receber através da fila produtor-consumidor.
A main thread gere o named pipe de registo, e coloca os pedidos de registo na fila produtor-consumidor.
Quando uma thread termina uma sessão, fica à espera de nova sessão para tratar.
As mensagens recebidas pelo servidor devem ser colocadas numa caixa.
Na prática, uma caixa corresponde a um ficheiro no TecnicoFS.
O ficheiro deve ser criado quando a caixa for criada pelo manager, e apagado quando a caixa for removida.
Todas as mensagens que vão sendo recebidas são escritas no fim do ficheiro, separadas por \0
.
Resumindo, as mensagens são acumuladas nas caixas. Quando um subscritor se liga a uma caixa, o ficheiro correspondente é aberto e as mensagens começam a ser lidas desde o início (mesmo que o mesmo subscritor ou outro já as tenha recebido antes). Ulteriores mensagens geradas pelo publisher de uma caixa deverão ser também entregues aos subscribers da caixa. Esta funcionalidade deverá ser implementada usando variáveis de condição com o objetivo de evitar esperas ativas.
Para uniformizar o output dos diversos comandos (para o stdout
), é fornecido o formato com que estas devem ser impressas.
A fila produtor-consumidor é a estrutura de sincronização mais complexa do projeto.
Por isso, esta componente vai ser avaliada em isolamento (i.e., existirão testes que usam apenas a interface descrita no producer-consumer.h
) para garantir a sua correção.
Como tal, a interface do producer-consumer.h
não deve ser alterada.
De resto, os grupos são livres de alterar o código base como lhes for conveniente.
fprintf(stdout, "%s\n", message);
Cada linha da listagem de caixas deve ser impressa da seguinte forma:
fprintf(stdout, "%s %zu %zu %zu\n", box_name, box_size, n_publishers, n_subscribers);
As caixas devem estar ordenadas por ordem alfabética, não sendo garantido que o servidor as envie por essa ordem (i.e., o cliente deve ordenar as caixas antes das imprimir).
No projeto, nunca devem ser usados mecanismos de espera ativa.
Sugere-se que implementem o projeto através dos seguintes passos:
- Implementar as interfaces de linha de comando (CLI) dos clientes;
- Implementar a serialização do protocolo de comunicação;
- Implementar uma versão básica do
mbroker
, onde só existe uma thread que, em ciclo, a) recebe um pedido de registo; b) trata a sessão correspondente; e c) volta a ficar à espera do pedido de registo; - Implementar a fila produtor-consumidor;
- Utilizar a fila produtor-consumidor para gerir e encaminhar os pedidos de registo para as worker threads.
A submissão é feita através do Fénix até sexta-feira, dia 13/Janeiro/2023, às 20h00.
Os estudantes devem submeter um ficheiro no formato zip
com o código fonte e o ficheiro Makefile
.
O arquivo submetido não deve incluir outros ficheiros (tais como binários).
Além disso, o comando make clean
deve limpar todos os ficheiros resultantes da compilação do projeto, bem como o comando make fmt
, para formatar automaticamente o código.
Recomendamos que os alunos se assegurem que o projeto compila/corre corretamente no ambiente de referência. Ao avaliar os projetos submetidos, em caso de dúvida sobre o funcionamento do código submetido, os docentes usarão o ambiente de referência para fazer a validação final. O uso de outros ambientes para o desenvolvimento/teste do projeto (e.g., macOS, Windows/WSL) é permitido, mas o corpo docente não dará apoio técnico a dúvidas relacionadas especificamente com esses ambientes.
A avaliação será feita de acordo com o método de avaliação descrito no Fénix
Bom trabalho!