DEV Community

Cover image for Construindo um web server em Assembly x86, the grand finale, multi-threading
Leandro Proença
Leandro Proença

Posted on

Construindo um web server em Assembly x86, the grand finale, multi-threading

Uma vez que temos um web server funcional, podemos dar o próximo (e último) passo, que é deixar o servidor minimamente escalável fazendo uso de uma pool de threads.

Neste artigo, vamos mergulhar nas entranhas da implementação de uma pool de threads com sincronização através de locks, e para atingir tal feito em assembly abordaremos filas, alocação dinâmica de memória e controle de locks com futex.

Ao fim deste artigo, que é o último da saga, teremos uma visão mais holística sobre como funciona um web server e como uma pool de threads poderia ser implementada em linguagens de baixo nível.

Respira e vem comigo, esta última parte será uma avalanche de conceitos.


Agenda


Simulando a latência com nanosleep

Quando uma requisição é feita a um web server, o tempo de resposta total é um somatório de toda a latência envolvida na comunicação, desde o momento em que o pedido sai da origem (client), passando pela rede de computadores (internet), chegando no destino (server), sendo processado, para então a resposta fazer o caminho inverso até voltar ao client.

Quanto maior a latência em qualquer parte do processo, maior o tempo de resposta, e portanto menor a capacidade de entregar respostas de diferentes requisições em um determinado intervalo de tempo.

server-database

A esta capacidade de processar requisições em um intervalo de tempo chamamos de throughput. O que queremos no fim das contas é aumentar o throughput sem comprometer a latência. Esta é uma das premissas para sistemas escaláveis, mas o foco deste artigo não será em escalabilidade necessariamente.

No artigo anterior, finalizamos o web server que apenas responde no socket uma mensagem HTML contendo "Hello, world". A seguir o código inicial do server, que será a base para o restante do artigo:

global _start

%define SYS_socket 41
%define SYS_bind 49
%define SYS_listen 50
%define SYS_accept4 288
%define SYS_write 1
%define SYS_close 3

%define AF_INET 2
%define SOCK_STREAM 1
%define SOCK_PROTOCOL 0
%define BACKLOG 2
%define CR 0xD
%define LF 0xA

section .data
sockaddr:
    sa_family: dw AF_INET   ; 2 bytes
    port: dw 0xB80B         ; 2 bytes
    ip_addr: dd 0           ; 4 bytes
    sin_zero: dq 0          ; 8 bytes
response: 
    headline: db "HTTP/1.1 200 OK", CR, LF
    content_type: db "Content-Type: text/html", CR, LF
    content_length: db "Content-Length: 22", CR, LF
    crlf: db CR, LF
    body: db "<h1>Hello, World!</h1>"
responseLen: equ $ - response

section .bss
sockfd: resb 1

section .text
_start:
.socket:
    ; int socket(int domain, int type, int protocol)
    mov rdi, AF_INET
    mov rsi, SOCK_STREAM
    mov rdx, SOCK_PROTOCOL
    mov rax, SYS_socket
    syscall
.bind:
    mov [sockfd], rax
    ; int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen)
    mov rdi, [sockfd]
    mov rsi, sockaddr
    mov rdx, 16
    mov rax, SYS_bind
    syscall
.listen:
    ; int listen(int sockfd, int backlog)
    mov rdi, [sockfd]
    mov rsi, BACKLOG
    mov rax, SYS_listen
    syscall
.accept:
    ; int accept(int sockfd, struct *addr, int addrlen, int flags)
    mov rdi, [sockfd]
    mov rsi, 0
    mov rdx, 0
    mov r10, 0
    mov rax, SYS_accept4
    syscall
    mov r8, rax
    call handle
    jmp .accept
handle:
    ; int write(fd)
    mov rdi, r8
    mov rsi, response
    mov rdx, responseLen
    mov rax, SYS_write
    syscall

    ; int close(fd)
    mov rdi, r8
    mov rax, SYS_close
    syscall
    ret
Enter fullscreen mode Exit fullscreen mode

Até aqui tudo normal. A rotina accept fica em loop chamando a rotina handle que escreve "Hello, world" na resposta de cada requisição que chega no socket.

Com strace, podemos ver as chamadas que foram feitas após uma requisição com curl:

socket(AF_INET, SOCK_STREAM, IPPROTO_IP) = 3
bind(3, {sa_family=AF_INET, sin_port=htons(3000), sin_addr=inet_addr("0.0.0.0")}, 16) = 0
listen(3, 2)                            = 0
accept4(3, NULL, NULL, 0)               = 4
write(4, "HTTP/1.1 200 OK\r\nContent-Type: t"..., 86) = 86
close(4)                                = 0
accept4(3, NULL, NULL, 0
Enter fullscreen mode Exit fullscreen mode

socket, bind, listen, para então iniciar o accept, que ao receber uma requisição HTTP, passa para write, close e então voltar ao accept novamente em loop.

Para simular um pouco de latência, vamos fazer com que a resposta demore cerca de 1 segundo, e para tanto precisamos utilizar uma syscall no Linux chamada nanosleep, que suspende a execução da thread atual até atingir um tempo decorrido especificado com base no relógio monotônico do sistema:

Primeiro definimos a syscall, que tem o código 35:

%define SYS_nanosleep 35
Enter fullscreen mode Exit fullscreen mode

Na rotina handle, antes de escrever a resposta no socket, fazemos a chamada de sistema para nanosleep passando como argumento uma struct que representa um timespec, que contempla o tempo decorrido em segundos e nano-segundos:

handle:
    ; int nanosleep(timespec duration)
    lea rdi, [timespec]
    mov rax, SYS_nanosleep
    syscall

    ; int write(fd)
    ...

    ; int close(fd)
    ...
Enter fullscreen mode Exit fullscreen mode

E na seção de dados, definimos o tempo decorrido em segundos, que são os primeiros 8 bytes da struct, deixando a zero os 8 bytes restantes que representam o tempo em nano-segundos

section .data
timespec:
    tv_sec: dq 1
    tv_nsec: dq 0
Enter fullscreen mode Exit fullscreen mode

Neste exemplo queremos que o sleep seja de 1 segundo

Com strace, podemos ver que a syscall nanosleep foi executada após o accept e antes do write:

socket(AF_INET, SOCK_STREAM, IPPROTO_IP) = 3
bind(3, {sa_family=AF_INET, sin_port=htons(3000), sin_addr=inet_addr("0.0.0.0")}, 16) = 0
listen(3, 2)                            = 0

accept4(3, NULL, NULL, 0)               = 4
nanosleep({tv_sec=1, tv_nsec=0}, NULL)  = 0
write(4, "HTTP/1.1 200 OK\r\nContent-Type: t"..., 86) = 86

close(4)                                = 0
accept4(3, NULL, NULL, 0
Enter fullscreen mode Exit fullscreen mode

Calculando o tempo decorrido com o utilitário time:

$ time curl localhost:3000

<h1>Hello, World!</h1>
real    0m1.040s
user    0m0.005s
sys     0m0.009s
Enter fullscreen mode Exit fullscreen mode

Podemos também encurtar a resposta do time trazendo apenas o tempo real, exportando a variável na sessão shell atual ou adicionando no bashrc:

export TIMEFORMAT=%R

$ time curl localhost:3000
<h1>Hello, World!</h1>1.036
Enter fullscreen mode Exit fullscreen mode

Yay! Já conseguimos simular uma latência de 1 segundo em Assembly. Agora vamos ver se nosso web server tem a capacidade de atender a requests em escala.


Simulando requests em escala com xargs

Para começar, vamos simular 10 requests sequenciais com curl. Poderíamos ficar digitando curl localhost:3000 10 vezes, ou então ser pragmáticos, automatizar sem reinventar a roda e nem instalar nada adicional no sistema.

Como?

xargs.

xargs é um utilitário presente na maioria dos sistemas operacionais UNIX-like, que lê strings a partir de arquivos ou standard input e utiliza estas strings como argumentos para comandos arbitrários.

Vamos ter como exemplo uma sequência de 1 a 10 em bash:

$ echo ${1..10}
1 2 3 4 5 6 7 8 9 10
Enter fullscreen mode Exit fullscreen mode

Podemos utilizar cada valor do echo como argumento para o xargs:

$ echo {1..10} | xargs -n1
1
2
3
4
5
6
7
8
9
10
Enter fullscreen mode Exit fullscreen mode

A opção -n1 significa a quantidade de argumentos que serão usados para o comando que vem a seguir ao xargs, que no caso queremos apenas 1 argumento, o que neste caso tanto faz pois não queremos fazer nada com o argumento: queremos apenas executar o comando curl 10 vezes.

Podemos então agora executar o curl com o time para saber o tempo decorrido de cada request:

$ time echo {1..10} | xargs -n1 bash -c "time curl localhost:3000"

<h1>Hello, World!</h1>1.037
<h1>Hello, World!</h1>1.033
<h1>Hello, World!</h1>1.025
<h1>Hello, World!</h1>1.037
<h1>Hello, World!</h1>1.032
<h1>Hello, World!</h1>1.026
<h1>Hello, World!</h1>1.019
<h1>Hello, World!</h1>1.046
<h1>Hello, World!</h1>1.053
<h1>Hello, World!</h1>1.041
10.426
Enter fullscreen mode Exit fullscreen mode

Claramente, vemos que cada request demorou cerca de 1 segundo, o que no total o tempo decorrido foi de 10,4 segundos. Esta é a latência total para o caso de fazermos requisições sequenciais.

E se fizermos requisições simultâneas? Num cenário mais próximo do real, vamos supor que nossa aplicação web recebe 10 requisições no mesmo segundo em horários de pico.

Para isto, conseguimos também utilizar o xargs para simular, através da opção -P, que representa a quantidade de processos simultâneos que o xargs irá utilizar para realizar os comandos.

Incrível! Com isto nosso web server atende 10 requisições simultâneas, fazendo com que o throughput total dos 10 requests fique em torno de 1 segundo, certo?

Calma, calabreso, vamos testar.

$ time echo {1..10} | xargs -n1 -P10 bash -c "time curl localhost:3000"

<h1>Hello, World!</h1>1.053
<h1>Hello, World!</h1>2.071
<h1>Hello, World!</h1>3.076
<h1>Hello, World!</h1>4.087
<h1>Hello, World!</h1>5.088
<h1>Hello, World!</h1>6.106
<h1>Hello, World!</h1>7.140
<h1>Hello, World!</h1>8.154
<h1>Hello, World!</h1>9.168
<h1>Hello, World!</h1>10.183
10.214
Enter fullscreen mode Exit fullscreen mode

Não melhorou nada! Ter 10 requests simultâneos não quer dizer que nosso server consiga atender os 10 requests ao mesmo tempo. Muito pelo contrário, pode até piorar e prejudicar a latência total, pois há diversos requests na fila esperando para serem atendidos.

  • o primeiro request demora 1 segundo
  • o segundo request chega ao mesmo tempo mas demora 2 segundos
  • o terceiro request chega ao mesmo tempo mas demora 3 segundos
  • e assim sucessivamente...

Nosso server é síncrono, e com isto podemos criar gargalos. Precisamos então que o server consiga lidar com concorrência.


Concorrência com forking de processos

Uma das formas primitivas de concorrência e escalar um web server para atender mais de um request em simultâneo é com o uso de processos. Como cada processo no sistema operacional tem sua memória isolada dos demais, podemos fazer com que cada request seja atendido em um processo diferente.

Para entender esta técnica, precisamos compreender que todo programa de computador roda em um processo no sistema operacional, e isto vimos bastante nos artigos anteriores. Dentro deste processo, o programa ainda roda em uma unidade de execução no SO chamada thread.

Todo processo tem uma thread chamada thread principal, que é onde está sendo executado o programa

No exemplo anterior, quando chamamos o sleep, a thread que está sendo suspensa por um tempo determinado é justamente a thread principal do programa.

A thread compartilha a memória do processo o qual ela faz parte, mas como precisamos criar outro processo, temos de fazer um forking, que basicamente cria um processo filho copiando tudo o que o programa principal tem.

forking de processos

Repare que cada processo filho tem uma cópia do processo principal. O loop é basicamente o accept do nosso web server, que fica em loop. Desta forma cada request pode ser atendido por um processo diferente, de forma concorrente.

Podemos fazer forking de processo com o uso da syscall fork:

%define SYS_fork 57
Enter fullscreen mode Exit fullscreen mode

A rotina handle mantém igual, com o sleep antes de escrever a resposta no socket:

handle:
    lea rdi, [timespec]
    mov rax, SYS_nanosleep
    syscall

    ; int write(fd)
    mov rdi, r8
    mov rsi, response
    mov rdx, responseLen
    mov rax, SYS_write
    syscall

    ; int close(fd)
    mov rdi, r8
    mov rax, SYS_close
    syscall
    ret
Enter fullscreen mode Exit fullscreen mode

E na rotina accept, adicionamos a chamada do fork logo após o request chegar no socket:

.accept:
    ; int accept(int sockfd, struct *addr, int addrlen, int flags)
    mov rdi, [sockfd]
    mov rsi, 0
    mov rdx, 0
    mov r10, 0
    mov rax, SYS_accept4
    syscall
    mov r8, rax

    ; fork de processo
    mov rax, SYS_fork
    syscall

    ; se o retorno do fork for ZERO, significa que está sendo executado
    ; a partir do processo filho. Então a rotina "handle" é executada
    test rax, rax
    jz handle

    ; quando o retorno não é ZERO, significa que a execução do programa 
    ; principal continuou. Então o processo principal volta para o loop
    jmp .accept
Enter fullscreen mode Exit fullscreen mode
  • depois de uma chamada ao fork, a syscall retorna ZERO quando se está dentro do processo filho. Neste caso, a execução do processo filho continua com a rotina handle e depois termina
  • após a chamada do fork, se o retorno NÃO for ZERO, significa que a execução é do programa principal, então neste caso volta-se ao loop para esperar um novo request no socket

Ao executar com strace, podemos ver várias chamadas à syscall fork:

socket(AF_INET, SOCK_STREAM, IPPROTO_IP) = 3
bind(3, {sa_family=AF_INET, sin_port=htons(3000), sin_addr=inet_addr("0.0.0.0")}, 16) = 0
listen(3, 2)                            = 0
accept4(3, NULL, NULL, 0)               = 4
fork(strace: Process 12787 attached
)                                  = 12787
[pid 12786] accept4(3, NULL, NULL, 0 <unfinished ...>
[pid 12787] nanosleep({tv_sec=1, tv_nsec=0},  <unfinished ...>
[pid 12786] <... accept4 resumed>)      = 5
[pid 12786] fork(strace: Process 12788 attached
)                      = 12788
[pid 12788] nanosleep({tv_sec=1, tv_nsec=0},  <unfinished ...>
[pid 12786] accept4(3, NULL, NULL, 0)   = 6
[pid 12786] fork(strace: Process 12789 attached
)                      = 12789
[pid 12786] accept4(3, NULL, NULL, 0)   = 7
[pid 12786] fork( <unfinished ...>
[pid 12789] nanosleep({tv_sec=1, tv_nsec=0}, strace: Process 12790 attached
 <unfinished ...>
[pid 12786] <... fork resumed>)         = 12790
[pid 12790] nanosleep({tv_sec=1, tv_nsec=0},  <unfinished ...>
[pid 12786] accept4(3, NULL, NULL, 0)   = 8
[pid 12786] fork(strace: Process 12791 attached
Enter fullscreen mode Exit fullscreen mode

E os tempos de resposta para 10 requests simultâneos:

$ time echo {1..10} | xargs -n1 -P10 bash -c "time curl localhost:3000"

<h1>Hello, World!</h1>1.049
<h1>Hello, World!</h1><h1>Hello, World!</h1><h1>Hello, World!</h1><h1>Hello, World!</h1>1.051
1.053
1.052
1.055
<h1>Hello, World!</h1>1.051
<h1>Hello, World!</h1>1.052
<h1>Hello, World!</h1>1.056
<h1>Hello, World!</h1>2.106
<h1>Hello, World!</h1>2.116
2.138
Enter fullscreen mode Exit fullscreen mode

Yay! Podemos ver que os requests são atendidos de forma concorrente, e que o tempo total ficou em 2,1 segundos para 10 requests simultâneos!

Lembrando que, quando estamos lidando com concorrência, não temos controle da ordem de execução dos processos, que são escalonados pelo sistema operacional. Esta preempção de processos pode fazer com que um request que chegou depois seja atendido primeiro. É uma das características de race condition e é por isso que vemos os requests chegando fora de ordem.

Mas no nosso caso não importa. Cada request é único e não depende do anterior.


Concorrência com clone de processo

Outra forma muito similar à chamada fork é através da syscall clone, que basicamente clona um processo, tal como fizemos no exemplo anterior, garantindo isolamento e concorrência.

%define SYS_clone 56
Enter fullscreen mode Exit fullscreen mode

E a diferença é que chamamos a syscall de clone, ao invés da syscall fork:

.accept:
    ; int accept(int sockfd, struct *addr, int addrlen, int flags)
    mov rdi, [sockfd]
    mov rsi, 0
    mov rdx, 0
    mov r10, 0
    mov rax, SYS_accept4
    syscall
    mov r8, rax

    ; chamada à syscall clone
    ; com argumentos a ZERO, significa que será feito um clone do processo
    mov rdi, 0
    mov rsi, 0
    mov rax, SYS_clone
    syscall

    ; se o retorno for zero, execução é a partir do processo filho
    test rax, rax
    jz handle

    ; continuação da execução do processo principal
    jmp .accept
Enter fullscreen mode Exit fullscreen mode
  • depois de uma chamada ao clone, a syscall retorna ZERO quando se está dentro do processo filho. Neste caso, a execução do processo filho continua com a rotina handle e depois termina
  • após a chamada do clone, se o retorno NÃO for ZERO, significa que a execução é do programa principal, então neste caso volta-se ao loop para esperar um novo request no socket

Executamos com strace e:

<h1>Hello, World!</h1><h1>Hello, World!</h1>1.062
1.064
<h1>Hello, World!</h1><h1>Hello, World!</h1>1.063
1.061
<h1>Hello, World!</h1><h1>Hello, World!</h1>1.071
1.061
<h1>Hello, World!</h1>1.059
<h1>Hello, World!</h1>1.069
<h1>Hello, World!</h1><h1>Hello, World!</h1>2.135
2.128
2.148
Enter fullscreen mode Exit fullscreen mode

Ainda servindo 10 requests simultâneos perto dos 2 segundos! Not bad.

Entretanto, forking ou clone de processos leva a um gasto excessivo de memória, pois cada processo filho é exatamente uma cópia do processo principal. Se o principal tem 200MB de memória, com 4 forks teríamos um gasto total de 800MB de memória.

Chegou o momento de falarmos das threads.


Concorrência com threads

Vamos relembrar o que falamos no início do artigo:

Todo processo tem uma thread chamada thread principal, que é onde está sendo executado o programa

Apesar de todo programa rodar dentro de uma thread, podemos também criar mais threads que compartilham a memória do mesmo processo, e para isto podemos fazer uso da mesma syscall clone, mas passando argumentos diferentes que tornam este clone uma thread dentro do mesmo processo, e não uma cópia inteira do processo.

Desta forma, ficamos sempre com UM processo mas atendendo requests em threads diferentes, gastando assim menos memória se comparado com forking de processos.

threads

Entendendo a criação de uma thread

Antes de adaptarmos o código do server para utilizar threads, vamos dar um passo atrás e entender como se cria uma thread em Assembly.

Para criar uma thread, fazemos uso da syscall clone. De acordo com a documentação, a syscall clone cria um processo "filho", similar ao que fizemos no fork. Mas a diferença é que a syscall clone permite um maior controle sobre o que será compartilhado entre o processo principal e o processo filho.

%define SYS_clone 56
Enter fullscreen mode Exit fullscreen mode

Coisas que podem ser compartilhadas (ou não):

  • espaço de endereço de memória virtual
  • tabela de descritores de arquivos
  • tabela de handlers de sinais
  • entre outros recursos...

No exemplo anterior utilizamos a syscall clone passando argumentos a ZERO, o que significa que não queríamos compartilhar nada entre os processos, portanto uma cópia seria feita como no forking de processos

Para a execução da syscall, precisamos enviar 2 argumentos:

  • rdi: representa as flags, que modificam o comportamento do que será compartilhado com o processo filho (thread)
  • rsi: ponteiro para a função que a thread irá executar, que precisa ser definido dentro de uma área reservada na memória, ou seja, precisamos alocar um novo bloco de memória para a thread poder colocar a função e seus argumentos

Portanto, para criar uma thread, precisamos de thread flags e alocação de memória.

Thread flags

Em RDI, vamos passar as seguintes flags:

  • CLONE_VM: processo principal e processo filho compartilham o mesmo espaço de memória virtual
  • CLONE_FS: processos compartilham o mesmo sistema de arquivos
  • CLONE_FILES: processos compartilham a mesma tabela de descritor de arquivos (file descriptor table)
  • CLONE_SIGHAND: processos compartilham a mesma tabela de handlers de sinais (signal handlers)
  • CLONE_PARENT: processos compartilham o mesmo parent, ou seja, o processo "filho" na verdade é filho do processo parent do processo original (mesmo porque estamos falando de uma thread que compartilha o mesmo processo)
  • CLONE_THREAD: o processo filho é colocado no mesmo grupo de threads do processo original
  • CLONE_IO: processos compartilham o mesmo contexto de I/O

No fim das contas, estamos criando um processo "filho" mas que compartilha recursos com o processo principal. Este é o princípio da thread.

mov rdi, CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_PARENT|CLONE_THREAD|CLONE_IO
Enter fullscreen mode Exit fullscreen mode

Alocação de memória com brk

Em RSI, precisamos indicar o ponteiro para a função na memória, neste caso o ponteiro da rotina handle, que tem a lógica de imprimir a mensagem e etc. Mas aqui não basta indicar o ponteiro, mas sim em que região da memória do processo a thread irá armazenar a função, seus argumentos e variáveis locais.

Cada thread precisa ter sua própria região na memória para armazenar a função e argumentos. É como se a thread tivesse uma área de "stack" só dela

Para alocar memória, vamos relembrar como funciona o layout de um programa na memória:

layout de memória

Nos endereços de memória mais baixos, temos o programa, e a seguir temos os dados estáticos (data). No topo, que é onde ficam os endereços mais altos, temos a stack.

E no meio, o que temos a seguir a seção de dados é uma área enorme disponível na memória. A syscall brk permite mudar o ponto onde termina a seção de dados, também chamado de program break.

Podemos ficar mudando este break em direção aos endereços mais altos. Por exemplo, se chamarmos a syscall brk passando argumento ZERO, ela devolve o endereço de memória do program break, que é onde termina a seção de dados:

%define SYS_brk 12

...

mov rdi, 0
mov rax, SYS_brk
syscall
Enter fullscreen mode Exit fullscreen mode

O que temos em RAX é 0x403000, que é exatamente o endereço de memória onde termina a seção de dados. Vamos modificar o break andando UM byte pra frente:

mov rdi, rax
add rdi, 1
mov rax, SYS_brk
syscall
Enter fullscreen mode Exit fullscreen mode

Agora, RAX traz o endereço do novo program break, que é 0x403001. Ou seja, agora podemos manipular este endereço de memória no nosso programa.

program break

E o quê isto tem a ver com a thread?

Podemos alocar uma quantidade arbitrária de bytes para a thread utilizar nesta área na memória. Como o break é sempre modificado, a próxima thread irá utilizar outra área de memória, e assim sucessivamente!

criação de threads

Um "Hello, world" com threads em Assembly

Vamos escrever um exemplo simples antes de ir para o web server. Primeiro, definimos as constantes, dentre elas as syscalls e as flags pra criação de threads:

global _start

%define SYS_brk 12
%define SYS_clone 56
%define SYS_write 1        
%define SYS_exit 60

%define STDOUT 1
%define CHILD_STACK_SIZE 4096

%define CLONE_VM 0x00000100
%define CLONE_FS 0x00000200
%define CLONE_FILES 0x00000400
%define CLONE_PARENT 0x00008000
%define CLONE_THREAD 0x00010000
%define CLONE_IO 0x80000000
%define CLONE_SIGHAND 0x00000800
Enter fullscreen mode Exit fullscreen mode

A seguir, na seção de dados, temos a mensagem "Hello, world" que a thread irá imprimir:

section .data
msg: db "Hello"
msgLen: equ $ - msg
Enter fullscreen mode Exit fullscreen mode

Na seção text, o entrypoint do programa faz uma chamada à rotina da thread e a seguir termina:

section .text
_start:
    call thread

    mov rdi, 0
    mov rax, SYS_exit
    syscall
Enter fullscreen mode Exit fullscreen mode

Agora, a definição da rotina handle que a thread irá executar:

handle:
    mov rdi, STDOUT
    mov rsi, msg
    mov rdx, msgLen
    mov rax, SYS_write
    syscall

    mov rdi, 0
    mov rax, SYS_exit
    syscall
Enter fullscreen mode Exit fullscreen mode

A thread imprime a mensagem no STDOUT e termina. Sim, a thread precisa terminar, caso contrário o sistema emite um segmentation fault

E por fim, vamos detalhar o processo da rotina da thread (explicação nos comentários do exemplo a seguir):

thread:
    ; Busca o break atual e guarda em RDX. Na primeira vez, o valor
    ; é 0x403000
    mov rdi, 0
    mov rax, SYS_brk
    syscall
    mov rdx, rax

    ; Modifica o break atual andando 4096 bytes à frente.
    ; Após esta chamada, o break passa a ser 0x404000
    mov rdi, rax
    add rdi, CHILD_STACK_SIZE
    mov rax, SYS_brk
    syscall

    ; (1) Thread flags: como deve ser feito o compartilhamento de recursos
    ; entre o processo principal e o processo filho
    mov rdi, CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_PARENT|CLONE_THREAD|CLONE_IO

    ; (2a) Endereço de memória em RSI: é o break atual + 4096 bytes.
    ; Retiramos também 8 bytes para caber o ponteiro da função
    lea rsi, [rdx + CHILD_STACK_SIZE - 8]

    ; (2b) No endereço em RSI colocamos o ponteiro da função "handle".
    ; Como endereçamento em x86_64 é de 8 bytes, é por isto
    ; que no passo anterior fizemos [rdx + 4096 - 8]
    mov qword [rsi], handle
    mov rax, SYS_clone
    syscall
    ret
Enter fullscreen mode Exit fullscreen mode

E pronto, ao executar o programa, temos a mensagem "Hello" na saída do programa que foi feita pela thread.

Caso o programa principal faça call thread 2 vezes, a próxima thread irá ter em RSI o break modificado, iniciando em 0x404000.

Modificando o server para suportar multi-threading

Agora vamos trazer o código necessário para modificar o server:

%define SYS_clone 56
%define SYS_brk 12
Enter fullscreen mode Exit fullscreen mode

Thread flags:

%define CHILD_STACK_SIZE 4096
%define CLONE_VM 0x00000100
%define CLONE_FS 0x00000200
%define CLONE_FILES 0x00000400
%define CLONE_PARENT 0x00008000
%define CLONE_THREAD 0x00010000
%define CLONE_IO 0x80000000
%define CLONE_SIGHAND 0x00000800
Enter fullscreen mode Exit fullscreen mode

Rotina accept:

.accept:
    ; int accept(int sockfd, struct *addr, int addrlen, int flags)
    mov rdi, [sockfd]
    mov rsi, 0
    mov rdx, 0
    mov r10, 0
    mov rax, SYS_accept4
    syscall
    mov r8, rax

    ; Chamada da thread. Irá ser executada assincronamente tal como no
    ; forking de processos, mas compartilhando a memória do
    ; processo principal
    call thread

    ; Processo principal volta para o loop
    jmp .accept
Enter fullscreen mode Exit fullscreen mode

Definição da thread:

thread:
    mov rdi, 0
    mov rax, SYS_brk
    syscall
    mov rdx, rax

    mov rdi, rax
    add rdi, CHILD_STACK_SIZE
    mov rax, SYS_brk
    syscall

    mov rdi, CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_PARENT|CLONE_THREAD|CLONE_IO
    lea rsi, [rdx + CHILD_STACK_SIZE - 8]
    mov qword [rsi], handle
    mov rax, SYS_clone
    syscall
    ret
Enter fullscreen mode Exit fullscreen mode

Aqui seguimos o mesmo padrão do exemplo anterior: aloca memória com brk e a seguir executa a syscall clone com as flags de compartilhamento de recursos

E a lógica da rotina handle que será executada pela thread, que faz o sleep, a seguir escreve no socket a mensagem, fecha o socket da requisição e por fim termina sua execução:

handle:
    lea rdi, [timespec]
    mov rax, SYS_nanosleep
    syscall

    ; int write(fd)
    mov rdi, r8
    mov rsi, response
    mov rdx, responseLen
    mov rax, SYS_write
    syscall

    ; int close(fd)
    mov rdi, r8
    mov rax, SYS_close
    syscall

    mov rdi, 0
    mov rax, SYS_exit
    syscall
Enter fullscreen mode Exit fullscreen mode

Com 1 chamada isolada, temos o seguinte output com strace:

socket(AF_INET, SOCK_STREAM, IPPROTO_IP) = 3
bind(3, {sa_family=AF_INET, sin_port=htons(3000), sin_addr=inet_addr("0.0.0.0")}, 16) = 0
listen(3, 2)                            = 0
accept4(3, NULL, NULL, 0)               = 4
brk(NULL)                               = 0x9da000
brk(0x9db000)                           = 0x9db000
clone(child_stack=0x9daff8, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_PARENT|CLONE_THREAD|CLONE_IOstrace: Process 13078 attached
) = 13078
[pid 13078] nanosleep({tv_sec=1, tv_nsec=0},  <unfinished ...>
[pid 13077] accept4(3, NULL, NULL, 0 <unfinished ...>
[pid 13078] <... nanosleep resumed>0x9daff8) = 0
[pid 13078] write(4, "HTTP/1.1 200 OK\r\nContent-Type: t"..., 86) = 86
[pid 13078] close(4)                    = 0
[pid 13078] exit(0)                     = ?
[pid 13078] +++ exited with 0 +++
<... accept4 resumed>)                  = ? ERESTARTSYS (To be restarted if SA_RESTART is set)
--- SIGWINCH {si_signo=SIGWINCH, si_code=SI_KERNEL} ---
accept4(3, NULL, NULL, 0
Enter fullscreen mode Exit fullscreen mode

Vamos reparar na sequência de chamadas:

  • depois do accept, foi feita uma chamada a brk modificando o program break (alocando memória)
  • a seguir, vemos a chamada clone, que acoplou o processo filho 13078
  • a thread (13078) é suspensa com nanosleep por 1 segundo
  • o processo principal (13077) volta para o accept e fica a espera de mais requests
  • a thread escreve no socket
  • a thread fecha o socket
  • a thread é encerrada com exit

Agora, simulando os 10 requests simultâneos:

<h1>Hello, World!</h1>1.060
<h1>Hello, World!</h1>1.064
<h1>Hello, World!</h1>1.064
<h1>Hello, World!</h1>1.062
<h1>Hello, World!</h1>1.068
<h1>Hello, World!</h1>1.073
<h1>Hello, World!</h1><h1>Hello, World!</h1><h1>Hello, World!</h1><h1>Hello, World!</h1>2.098
2.094
2.097
2.091
2.113
Enter fullscreen mode Exit fullscreen mode

Superb! Temos o mesmo tempo total de 2,1 segundos mas gastando muito menos memória!

Entretanto, temos um pequeno problema. Imagina que num momento de grande número de acessos, a nossa aplicação recebe 1000 requests concorrentes. E se receber 5000? Ou então dezenas de milhares de requests simultâneos?

Uma chamada de sistema tem custo. O sistema operacional oferece um limite de threads que podem ser criadas ao mesmo tempo por processo. Se deixarmos assim, nossa aplicação corre um grande risco de ultrapassar esse limite, além de que chamadas a brk + clone têm seus custos de criação.

E se pudéssemos reciclar um número limitado de threads? Sim, estamos falando de pool de threads.


Concorrência com thread pool

A forma mais comum de trabalhar com thread é com thread pool. Basicamente, definimos um número arbitrário de threads que nunca terminam, mas ficam em loop consumindo mensagens de alguma estrutura de dados. Esta estrutura pode ser uma fila.

thread pool

Uma thread em loop

Vamos inicialmente definir que teremos apenas UMA thread em loop lendo mensagens da fila. O processo deverá ser o seguinte:

  • processo principal inicia uma thread
  • a thread fica em loop lendo mensagens (socket) da fila. Quando tiver vazia, repete o loop. Quando houver algum socket na fila, a thread executa a lógica que é fazer o nanosleep, escrever no socket, fechar o socket, e voltar para o loop de leitura da fila
  • processo principal continua execução após criação da thread, onde fica em loop lendo requisições que chegam no socket (accept). Quando uma requisição chega, adiciona o socket na fila e volta para o loop do accept.

Vamos passo a passo, começando pelas constantes:

global _start

%define SYS_socket 41
%define SYS_bind 49
%define SYS_listen 50
%define SYS_accept4 288
%define SYS_write 1
%define SYS_close 3

%define SYS_nanosleep 35
%define SYS_clone 56
%define SYS_brk 12
%define SYS_exit 60

%define AF_INET 2
%define SOCK_STREAM 1
%define SOCK_PROTOCOL 0
%define BACKLOG 2
%define CR 0xD
%define LF 0xA

%define CHILD_STACK_SIZE 4096
%define CLONE_VM 0x00000100
%define CLONE_FS 0x00000200
%define CLONE_FILES 0x00000400
%define CLONE_PARENT 0x00008000
%define CLONE_THREAD 0x00010000
%define CLONE_IO 0x80000000
%define CLONE_SIGHAND 0x00000800
Enter fullscreen mode Exit fullscreen mode

A seguir, a seção de dados:

section .data
sockaddr:
    sa_family: dw AF_INET   ; 2 bytes
    port: dw 0xB80B         ; 2 bytes
    ip_addr: dd 0           ; 4 bytes
    sin_zero: dq 0          ; 8 bytes
response: 
    headline: db "HTTP/1.1 200 OK", CR, LF
    content_type: db "Content-Type: text/html", CR, LF
    content_length: db "Content-Length: 22", CR, LF
    crlf: db CR, LF
    body: db "<h1>Hello, World!</h1>"
responseLen: equ $ - response
timespec:
    tv_sec: dq 1
    tv_nsec: dq 0
queuePtr: db 0

section .bss
sockfd: resb 8
queue: resb 8
Enter fullscreen mode Exit fullscreen mode

Repare que a fila representa 8 bytes fixos (para nosso exemplo é o suficiente), utilizando também um ponteiro para manipular a fila.

Seguindo com o código, o programa inicia logo disparando a thread:

section .text
_start:
    call thread        
Enter fullscreen mode Exit fullscreen mode

A seguir vêm as rotinas habituais (vou omitir pra poupar caracteres neste artigo): socket, bind e listen.

O accept fica da seguinte forma:

.accept:
    ; int accept(int sockfd, struct *addr, int addrlen, int flags)
    mov rdi, [sockfd]
    mov rsi, 0
    mov rdx, 0
    mov r10, 0
    mov rax, SYS_accept4
    syscall

    mov r8, rax
    call enqueue

    jmp .accept
Enter fullscreen mode Exit fullscreen mode

Okay, agora, ao invés de chamar uma thread, o programa principal enfileira o socket da requisição. Lógica do enqueue:

enqueue:
    xor rdx, rdx
    mov dl, [queuePtr]  
    mov [queue + rdx], r8   
    inc byte [queuePtr]
    ret
Enter fullscreen mode Exit fullscreen mode

Aqui, estamos manipulando o ponteiro em queue utilizando queuePtr, incrementando um byte quando algo é adicionado na fila.

Agora vamos à implementação da rotina da thread:

thread:
    mov rdi, 0
    mov rax, SYS_brk
    syscall
    mov rdx, rax

    mov rdi, rax
    add rdi, CHILD_STACK_SIZE
    mov rax, SYS_brk
    syscall

    mov rdi, CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_PARENT|CLONE_THREAD|CLONE_IO
    lea rsi, [rdx + CHILD_STACK_SIZE - 8]
    mov qword [rsi], handle
    mov rax, SYS_clone
    syscall
    ret
Enter fullscreen mode Exit fullscreen mode

Nada de novo por enquanto. O que modifica é a rotina handle (explicação detalhada nos comentários):

handle:
    ; Verifica se a fila está vazia. Se estiver, fica em loop infinito.
    ; Repare que este código não está otimizado. Loop infinito 
    ; acarreta alto consumo de CPU. Nas próximas seções vamos resolver isto.
    ; Por ora, vamos aceitar este consumo de CPU.
    cmp byte [queuePtr], 0
    je handle

    ; Remove (faz pop) da fila de socket, e guarda em R8.
    call dequeue
    mov r8, rax

    ; Processo normal, faz o nanosleep de 1 segundo simulando latência
    lea rdi, [timespec]
    mov rax, SYS_nanosleep
    syscall

    ; Escreve no socket que está em R8
    ; int write(fd)
    mov rdi, r8
    mov rsi, response
    mov rdx, responseLen
    mov rax, SYS_write
    syscall

    ; Fecha o socket
    ; int close(fd)
    mov rdi, r8
    mov rax, SYS_close
    syscall

    ; Volta para o início (loop)
    jmp handle
Enter fullscreen mode Exit fullscreen mode

E por último, a lógica da rotina dequeue:

dequeue:
    xor rax, rax
    xor rsi, rsi

    mov al, [queue]
    mov rcx, 0
.loop_dequeue:
    cmp byte [queuePtr], 0
    je .return_dequeue

    cmp cl, [queuePtr]
    je .done_dequeue

    ; shift
    xor r10, r10
    mov r10b, [queue + rcx + 1]
    mov byte [queue + rcx], r10b

    inc rcx
    jmp .loop_dequeue
.done_dequeue:
    dec byte [queuePtr]
.return_dequeue:
    ret
Enter fullscreen mode Exit fullscreen mode

Por enquanto não vou entrar em detalhes em como trabalhar com filas em Assembly. Vou deixar estes detalhes para outro artigo, que irá tratar especificamente de arrays, filas e listas ligadas. Em breve!

Pronto, já podemos executar o server e...

<h1>Hello, World!</h1>1.042
<h1>Hello, World!</h1>2.059
<h1>Hello, World!</h1>3.071
<h1>Hello, World!</h1>4.083
<h1>Hello, World!</h1>5.090
<h1>Hello, World!</h1>6.094
<h1>Hello, World!</h1>7.112
<h1>Hello, World!</h1>8.121
<h1>Hello, World!</h1>9.140
<h1>Hello, World!</h1>10.150
10.166
Enter fullscreen mode Exit fullscreen mode

Ouch! Voltamos aos 10 segundos. Mas isto se deve ao fato de termos apenas uma thread em loop. Vamos aumentar o número de threads na pool.

5 threads em loop

A seguir modificamos o programa para inicializar 5 threads, para que desta forma nosso server tenha mais capacidade em atender requests simultâneos:

section .text
_start:
.initialize_pool:
    mov r8, 0
.pool:
    call thread        
    inc r8
    cmp r8, 5
    je .socket
    jmp .pool
....
....
Enter fullscreen mode Exit fullscreen mode

Com este loop fazemos call thread 5 vezes, pelo que cada thread, e ainda utilizando o exemplo anterior, irá ficar em loop buscando mensagens na fila.

Executamos o código com 1 request e temos sucesso:

$ time curl localhost:3000

<h1>Hello, World!</h1>1.022
Enter fullscreen mode Exit fullscreen mode

Mas no output do strace, após a resposta, vemos uma sequência de erros das threads:

<h1>Hello, World!</h1>[pid 13483] write(4, "HTTP/1.1 200 OK\r\nContent-Type: t"..., 86 <unfinished ...>
[pid 13482] write(0, "HTTP/1.1 200 OK\r\nContent-Type: t"..., 86 <unfinished ...>
[pid 13480] write(0, "HTTP/1.1 200 OK\r\nContent-Type: t"..., 86 <unfinished ...>
[pid 13484] <... write resumed>)        = 86
[pid 13481] write(4, "HTTP/1.1 200 OK\r\nContent-Type: t"..., 86HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 22

<h1>Hello, World!</h1> <unfinished ...>
[pid 13480] <... write resumed>)        = 86
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 22

<h1>Hello, World!</h1>[pid 13484] close(0 <unfinished ...>
[pid 13483] <... write resumed>)        = 86
[pid 13482] <... write resumed>)        = 86
[pid 13480] close(0 <unfinished ...>
[pid 13484] <... close resumed>)        = 0
[pid 13481] <... write resumed>)        = 86
[pid 13484] nanosleep({tv_sec=1, tv_nsec=0},  <unfinished ...>
[pid 13483] close(4 <unfinished ...>
[pid 13482] close(0 <unfinished ...>
[pid 13481] close(4 <unfinished ...>
[pid 13480] <... close resumed>)        = -1 EBADF (Bad file descriptor)
[pid 13483] <... close resumed>)        = 0
[pid 13482] <... close resumed>)        = -1 EBADF (Bad file descriptor)
[pid 13481] <... close resumed>)        = -1 EBADF (Bad file descriptor)
Enter fullscreen mode Exit fullscreen mode

A thread tentou fechar o socket descriptor da requisição mas outra thread tentou ler o socket de forma concorrente (Bad file descriptor).

Quando envolve mais de uma thread consumindo o mesmo recurso (neste caso a fila), precisamos de um mecanismo de sincronização, que no caso são locks.

Sincronização com futex

Com locks, conseguimos controlar o acesso a um recurso compartilhado entre diferentes threads.

Através da syscall futex, podemos suspender uma thread baseando-se em uma "variável condicional". De forma oposta, podemos tornar uma thread de volta à execução baseando-se também na variável condicional.

Esta técnica de variável condicional (condvar) é um primitivo de sincronização bastante utilizado. No nosso caso para controle da fila, queremos o seguinte cenário:

  • a thread verifica se há algo na fila. Caso a fila esteja vazia, a thread é suspensa com futex wait através de uma variável condicional
  • quando algo for adicionado na fila, outra thread/processo "emite um sinal" chamando a syscall futex wake na mesma variável condicional.
  • quando o sinal é emitido, neste momento a thread que tem o acesso ao lock (variável condicional) é trazida de volta ao contexto, então lê a mensagem da fila e executa a ação necessária. Após, se a fila estiver vazia, repete o processo com futex wait e fica novamente suspensa

Desta forma, garantimos que as threads não ficam consumindo a CPU em loop indefinidamente

Modificando o código, começamos por definir a syscall:

%define SYS_futex 202
Enter fullscreen mode Exit fullscreen mode

A seguir, na seção de dados .bss declaramos a variável condicional ocupando 8 bytes, que será utilizada como sincronização do futex:

section .bss
...
condvar: resb 8
Enter fullscreen mode Exit fullscreen mode

Na rotina enqueue, emitimos o sinal após o socket ser adicionado na fila:

enqueue:
    xor rdx, rdx
    mov dl, [queuePtr]  
    mov [queue + rdx], r8   
    inc byte [queuePtr]

    call emit_signal
    ret
Enter fullscreen mode Exit fullscreen mode

A lógica o emit_signal (explicação nos comentários):

emit_signal:
   ; Endereço de memória para a variável condicional (8 bytes)
   mov rdi, condvar

   ; Flags do Futex (WAKE), que irá trazer a thread
   ; de volta ao contexto
   mov rsi, FUTEX_WAKE | FUTEX_PRIVATE_FLAG 

   ; Argumentos adicionais, que neste caso vamos deixar a ZERO
   xor rdx, rdx
   xor r10, r10
   xor r8, r8

   ; Chamada da syscall
   mov rax, SYS_futex
   syscall
   ret
Enter fullscreen mode Exit fullscreen mode

Agora, modificamos a rotina handle:

handle: 
    ; Caso a fila esteja vazia, fazemos jump para "wait"
    cmp byte [queuePtr], 0         
    je .wait           

    ; Faz pop do socket da fila e segue o fluxo normal
    call dequeue      
    mov r10, rax

    lea rdi, [timespec]
    mov rax, SYS_nanosleep
    syscall

    ; int write(fd)
    mov rdi, r10
    mov rsi, response
    mov rdx, responseLen
    mov rax, SYS_write
    syscall

    ; int close(fd)
    mov rdi, r10
    mov rax, SYS_close
    syscall

    ; Volta para o início
    jmp handle       
.wait:
    ; Chamada para wait_condvar, que vai suspender a thread atual com FUTEX
    call wait_condvar 
    jmp handle       
Enter fullscreen mode Exit fullscreen mode

E, por último e não menos importante, a lógica da rotina wait_condvar, que suspende a thread de execução:

wait_condvar:
   ; Endereço de memória para a variável condicional (8 bytes)
   mov rdi, condvar   

   ; Flags do Futex (WAIT), que irá suspender a thread
   mov rsi, FUTEX_WAIT | FUTEX_PRIVATE_FLAG 
   xor rdx, rdx
   xor r10, r10              
   xor r8, r8               
   mov rax, SYS_futex
   syscall
   test rax, rax
   jz .done_condvar
.done_condvar:
   ret
Enter fullscreen mode Exit fullscreen mode

Assim que iniciamos o server com strace, podemos ver as syscalls em ação:

brk(NULL)                               = 0x155c000
brk(0x155d000)                          = 0x155d000
clone(child_stack=0x155cff8, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_PARENT|CLONE_THREAD|CLONE_IOstrace: Process 13539 attached
) = 13539
[pid 13539] futex(0x402088, FUTEX_WAIT_PRIVATE, 0, NULL <unfinished ...>
[pid 13538] brk(NULL)                   = 0x155d000
[pid 13538] brk(0x155e000)              = 0x155e000
[pid 13538] clone(child_stack=0x155dff8, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_PARENT|CLONE_THREAD|CLONE_IOstrace: Process 13540 attached
) = 13540
[pid 13540] futex(0x402088, FUTEX_WAIT_PRIVATE, 0, NULL <unfinished ...>
[pid 13538] brk(NULL)                   = 0x155e000
[pid 13538] brk(0x155f000)              = 0x155f000
[pid 13538] clone(child_stack=0x155eff8, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_PARENT|CLONE_THREAD|CLONE_IOstrace: Process 13541 attached
) = 13541
[pid 13541] futex(0x402088, FUTEX_WAIT_PRIVATE, 0, NULL <unfinished ...>
[pid 13538] brk(NULL)                   = 0x155f000
[pid 13538] brk(0x1560000)              = 0x1560000
[pid 13538] clone(child_stack=0x155fff8, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_PARENT|CLONE_THREAD|CLONE_IOstrace: Process 13542 attached
) = 13542
[pid 13542] futex(0x402088, FUTEX_WAIT_PRIVATE, 0, NULL <unfinished ...>
[pid 13538] brk(NULL)                   = 0x1560000
[pid 13538] brk(0x1561000)              = 0x1561000
[pid 13538] clone(child_stack=0x1560ff8, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_PARENT|CLONE_THREAD|CLONE_IOstrace: Process 13543 attached
) = 13543
[pid 13538] socket(AF_INET, SOCK_STREAM, IPPROTO_IP <unfinished ...>
[pid 13543] futex(0x402088, FUTEX_WAIT_PRIVATE, 0, NULL <unfinished ...>
[pid 13538] <... socket resumed>)       = 3
[pid 13538] bind(3, {sa_family=AF_INET, sin_port=htons(3000), sin_addr=inet_addr("0.0.0.0")}, 16) = 0
[pid 13538] listen(3, 2)                = 0
[pid 13538] accept4(3, NULL, NULL, 0)   = ? ERESTARTSYS (To be restarted if SA_RESTART is set)
[pid 13538] --- SIGWINCH {si_signo=SIGWINCH, si_code=SI_KERNEL} ---
[pid 13538] accept4(3, NULL, NULL, 0
Enter fullscreen mode Exit fullscreen mode

Repare que cada thread faz a chamada a futex com FUTEX WAIT, e isto vemos acontecer 5 vezes no trace. Ou seja, as 5 threads estão suspensas sem consumir CPU.

Ao fazer o primeiro request, temos o seguinte resultado:

)   = 4
[pid 13538] futex(0x402088, FUTEX_WAKE_PRIVATE, 0) = 1
[pid 13539] <... futex resumed>)        = 0
[pid 13538] accept4(3, NULL, NULL, 0 <unfinished ...>
[pid 13539] nanosleep({tv_sec=1, tv_nsec=0}, NULL) = 0
[pid 13539] write(4, "HTTP/1.1 200 OK\r\nContent-Type: t"..., 86) = 86
[pid 13539] close(4)                    = 0
[pid 13539] futex(0x402088, FUTEX_WAIT_PRIVATE, 0, NULL
Enter fullscreen mode Exit fullscreen mode
  • o processo principal recebeu a mensagem no socket, enfileirou e executou futex com FUTEX_WAKE
  • uma das threads foi trazida de volta ao contexto e fez a sua devida execução (nanosleep + write + close)
  • o processo principal voltou para o accept a espera de mais requests no socket
  • a thread terminou seu trabalho, viu que não tinha mais nada na fila e executou futex com FUTEX_WAIT, ficando novamente suspensa

Finalmente, podemos executar 10 requests simultâneos e...

<h1>Hello, World!</h1><h1>Hello, World!</h1><h1>Hello, World!</h1>1.079
1.082
1.083
<h1>Hello, World!</h1><h1>Hello, World!</h1>1.062
1.083
<h1>Hello, World!</h1><h1>Hello, World!</h1>2.087
2.088
<h1>Hello, World!</h1>2.100
<h1>Hello, World!</h1>2.101
<h1>Hello, World!</h1>2.110
2.127
Enter fullscreen mode Exit fullscreen mode

Nice! Com uma pool de 5 threads, conseguimos atingir 2,1 segundos para 10 requests concorrentes. Agora temos concorrência consumindo muito menos recursos:

  • menos memória, pois não é forking de processos
  • menos CPU, pois as threads não ficam em loop infinito
  • menos latência, pois com uma limitação de 5 threads, novos requests não criam novas threads

Alocação de memória com mmap

Um problema comum ao utilizar brk é que a memória pode ficar fragmentada. Uma vez que o program break foi modificado, aquela memória pode ser utilizada, mas torna muito difícil ser reciclada.

Uma forma de lidar com este problema de fragmentação é utilizar uma syscall que trata de reservar uma área na memória que pode ser reciclada futuramente. Estamos falando da syscall mmap.

thread:
    mov rdi, 0x0
    mov rsi, CHILD_STACK_SIZE
    mov rdx, PROT_WRITE | PROT_READ
    mov r10, MAP_ANONYMOUS | MAP_PRIVATE | MAP_GROWSDOWN
    mov rax, SYS_mmap
    syscall

    mov rdi, CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_PARENT|CLONE_THREAD|CLONE_IO
    lea rsi, [rax + CHILD_STACK_SIZE - 8]
    mov qword [rsi], handle
    mov rax, SYS_clone
    syscall
    ret
Enter fullscreen mode Exit fullscreen mode

Ao invés de chamar brk, podemos chamar mmap, especificando:

  • rdi: endereço de memória onde deve ser mapeado. Se tiver ZERO, o sistema operacional se encarrega de trazer um endereço de memória disponível
  • rsi: tamanho do espaço reservado na memória. No nosso caso, queremos 4096 bytes para a thread
  • rdx: proteção de memória, neste caso queremos que a memória possa ser tanto escrita (PROT_WRITE) quanto lida (PROT_READ) pela thread
  • r10: flags de mapeamento
    • MAP_ANONYMOUS: o mapeamento não é associado a nenhum arquivo ou descritor de arquivo (modo anônimo)
    • MAP_PRIVATE: mapeamento privado com copy-on-write, ou seja, os dados serão copiados à medida em que são escritos
    • MAP_GROWSDOWN: o mapeamento é usado no formato "stack", ou seja, o mapeamento é dos endereços maiores em direção aos menores

Com mmap, podemos fazer uso da sua contrapartida, a syscall unmmap, que permite reciclar uma determinada área na memória que não é mais utilizada, evitando fragmentação.

Esta técnica é muito utilizada pela libc através da função malloc. Com mmap podemos definir uma memória heap para nosso programa

É isto, na prática não terá nenhum efeito com relação ao exemplo anterior. Mas esta seção foi apenas para trazer uma forma diferente de alocar memória para a thread.


Conclusão

Ufa! Finalmente chegamos ao final da saga. Passamos por uma introdução, onde a seguir fizemos uma abordagem pela história e arquitetura de computadores, para então analisar código de máquina, que foi a base para entrar em assembly x86 de fato, para finalmente concluir o desenvolvimento do web server.

Este último artigo foi uma abordagem para multi-threading em Assembly, passando por conceitos de concorrência como forking de processos, clone, threading, pool de threads e sincronização com locks.

Declaro aqui então o fim da saga desenvolvendo um web server em Assembly x86.

Até a próxima saga!


Referências


Synchronization: mutexes and condition variables
https://cs61.seas.harvard.edu/site/2018/Synch3/
Synchronization, atomics and mutexes
https://cs.brown.edu/courses/csci0300/2023/notes/l22.html
Basics of Futexes
https://eli.thegreenplace.net/2018/basics-of-futexes/
Raw Linux threads via syscalls
https://nullprogram.com/blog/2015/05/15/
Condition variables with Futex
https://www.remlab.net/op/futex-condvar.shtml

Top comments (1)

Collapse
 
pedro_lucas_0970ecb6b49a4 profile image
Pedro Lucas

Parabéns, excelente exemplo de x86 para termos um servidor compacto e rápido, em vez da tralha que tantas linguagens agregam para fazer o mesmo, mais lento e muito maior.