Kontynuujemy nasze zmagania z przetwarzaniem zapytań. W pierwszej części zdefiniowaliśmy problem i podjęliśmy pierwsze próby implementacji rozwiązania. Szybko jednak okazało się, że istnieją lepsze metody. Dzisiaj będziemy eksperymentować z wielowątkowością. Oczywiście nie obejdzie się bez problemów…
Kod źródłowy eksperymentów można znaleźć tutaj.
Przejdźmy do rzeczy – ostanie wyniki pokazały, że nasz procesor się nudzi. Zapytania są niezależne więc wielowątkowość wydaje się być naturalnym remedium. Zróbmy więc tak jak robi wiele serwerów – każde zapytanie będzie obsługiwane przez osobny wątek.
Nasza (naiwna) implementacja będzie wyglądać mniej więcej tak:
for_each request in 100k_requests:
create_thread(RequestProcessor::Process(request))
Uruchamiamy więc nasz program:
$ ./processing_pipeline 2
Running Experiment_2_thread_per_req
Experiment interrupted - exception thrown: Resource temporarily unavailable
Only 32747 threads created
Waited 3951.76 ms, avg 25305.2 trans/sec
Hmm… Program przerwał działanie. Widzimy, że udało się stworzyć 32 747 wątków. Prawdopodobnie nie powiodło się stworzenie kolejnego wątku. Sprawdźmy tą hipotezę:
$ gdb ./processing_pipeline
...
(gdb) catch throw
(gdb) run 2
...
(gdb) bt
#0 0x00007ffff78c58bd in __cxa_throw () from /usr/lib/x86_64-linux-gnu/libstdc++.so.6
#1 0x00007ffff78ee7fe in std::__throw_system_error(int) () from /usr/lib/x86_64-linux-gnu/libstdc++.so.6
#2 0x00007ffff78f0e73 in std::thread::_M_start_thread(...)
#3 0x000000000040eabe in std::thread::thread<...>(...)...
<...>
#7 0x0000000000404705 in Experiment_2_thread_per_req () at main.cpp:92
#8 0x00000000004058ce in main (argc=2, argv=0x7fffffffdcb8) at main.cpp:294
Tak, próba stworzenia nowego obiektu std::thread
nie powiodła się. Zapewne przekroczyliśmy jakiś limit. Pytanie tylko o jaki limit chodzi? Bo nie ma czegoś takiego jak limit wątków… W systemie Linux mamy kilka limitów które mogą ograniczać bezpośrednio lub pośrednio liczbę wątków i/lub procesów:
/proc/sys/kernel/threads-max
. U mnie ustawiony jest na:
$ cat /proc/sys/kernel/threads-max
513640
/proc/sys/kernel/pid_max
, wartość jest obliczana przez kernel na podstawie prostej formuły: 1024 * liczba_rdzeni * liczba_wątków_per_rdzeń.
$ cat /proc/sys/kernel/pid_max
32768
/proc/sys/vm/max_map_count
:
$ cat /proc/sys/vm/max_map_count
65530
A co to oznacza w praktyce? Zajrzyjmy w dokumentację:
max_map_count: This file contains the maximum number of memory map areas a process may have. Memory map areas are used as a side-effect of calling malloc, directly by mmap, mprotect, and madvise, and also when loading shared libraries. While most applications need less than a thousand maps, certain programs, particularly malloc debuggers, may consume lots of them, e.g., up to one or two maps per allocation. The default value is 65536.
W skrócie można powiedzieć, że licznik zmapowanych obszarów pamięci powiększa się kiedy np. ładujemy bibliotekę so, kiedy alokator pamięci dostaje nowy blok z kernela, bezpośrednie wywołania mmap
, mprotect
, madvise
, i co dla nas najważniejsze – tworzenie wątku.
ulimit -a
ale mamy więcej detali):
$ cat /proc/self/limits
Limit Soft Limit Hard Limit Units
Max cpu time unlimited unlimited seconds
Max file size unlimited unlimited bytes
Max data size unlimited unlimited bytes
Max stack size 8388608 unlimited bytes
Max core file size 0 unlimited bytes
Max resident set unlimited unlimited bytes
Max processes 256820 256820 processes
Max open files 1024 1048576 files
Max locked memory 65536 65536 bytes
Max address space unlimited unlimited bytes
Max file locks unlimited unlimited locks
Max pending signals 256820 256820 signals
Max msgqueue size 819200 819200 bytes
Max nice priority 0 0
Max realtime priority 0 0
Max realtime timeout unlimited unlimited us
$ ulimit -s
8192
Co oznacza 8192 kB * 1024 = 8388608M = 8 MB. Teoretycznie mamy miejsce w wirtualnej pamięci na 128 TB / 8 MB = 16 777 216 wątków.
max user processes
.
$ ulimit -a
core file size (blocks, -c) 0
data seg size (kbytes, -d) unlimited
scheduling priority (-e) 0
file size (blocks, -f) unlimited
pending signals (-i) 256820
max locked memory (kbytes, -l) 64
max memory size (kbytes, -m) unlimited
open files (-n) 1024
pipe size (512 bytes, -p) 8
POSIX message queues (bytes, -q) 819200
real-time priority (-r) 0
stack size (kbytes, -s) 8192
cpu time (seconds, -t) unlimited
max user processes (-u) 256820
virtual memory (kbytes, -v) unlimited
file locks (-x) unlimited
Ok, uzbrojeni w pewną wiedzę teoretyczną kontynuujmy nasze śledztwo. Jak zwykle warto zobaczyć jakie funkcje systemowe zostały wywołane, ile i z jakim skutkiem:
$ strace -c ./processing_pipeline 2
Running Experiment_2_thread_per_req
Experiment interrupted - exception thrown: Resource temporarily unavailable
Only 32747 threads created
Waited 2993.39 ms, avg 33406.9 trans/sec
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
28.10 0.000558 0 32747 clone
26.13 0.000519 0 32759 1 mprotect
24.07 0.000478 0 32748 munmap
19.18 0.000381 0 32770 mmap
1.81 0.000036 0 3806 brk
0.70 0.000014 0 291 18 futex
0.00 0.000000 0 6 read
0.00 0.000000 0 8 write
0.00 0.000000 0 7 open
0.00 0.000000 0 7 close
0.00 0.000000 0 7 fstat
0.00 0.000000 0 3 lseek
0.00 0.000000 0 2 rt_sigaction
0.00 0.000000 0 1 rt_sigprocmask
0.00 0.000000 0 7 7 access
0.00 0.000000 0 1 execve
0.00 0.000000 0 1 getrlimit
0.00 0.000000 0 1 arch_prctl
0.00 0.000000 0 1 set_tid_address
0.00 0.000000 0 1 set_robust_list
------ ----------- ----------- --------- --------- ----------------
100.00 0.001986 135174 26 total
Widzimy, że wywołanie funkcji futex
dało negatywne rezultaty aż 18 razy. Jednak sama funkcja została wywołana tylko 291 razy co nie odpowiada ilości stworzonych wątków. Za to ciekawy trop daje nam funkcja systemowa mprotect
– wywołana została 32 759 razy czyli prawie tyle co liczba naszych wątków (32 747). Ale najciekawsze jest to, że tylko jeden raz zwróciła błąd. Sprawdźmy co to za błąd:
$ strace -e trace=mprotect ./processing_pipeline 2 2>&1| grep "= -1"
mprotect(0x7fa762ba1000, 4096, PROT_NONE) = -1 ENOMEM (Cannot allocate memory)
Dokumentacja lakonicznie stwierdza:
ENOMEM Internal kernel structures could not be allocated.
Ale możemy zrobić dwie rzeczy – rzucić okiem w kod źródłowy kernela albo zaryzykować hipotezę, że limit mapowania obszarów pamięci został przekroczony (mprotect
jest wymieniana jako funkcja mogąca naruszyć ten limit). Jednak aby mieć podstawy do uznania hipotezy za prawdopodobną trzeba wykazać, że ograniczenie 65530 zostało naruszone. Więc policzmy liczbę wywołań: 32759 − 1 (mprotect) + 32770 (mmap) = 65528. Różnica może wynikać z dodatkowych czynności jak ładowanie bibliotek so. Przegląd kodu kernela nie daje jednoznacznej odpowiedzi ponieważ istnieje wiele ścieżek prowadzących do ENOMEM. Najciekawsze funkcje pomocnicze to may_expand_vm
, __vm_enough_memory
i find_vma
które sprawdzają różne limity więc trudno wskazać jednoznacznie przyczynę bez głębszej analizy.
Ale jedną rzecz łatwo zrobić – mamy już hipotezę o przekroczonym limicie mapowania pamięci, liczby potwierdzają nasze przypuszczenia – pozostał więc eksperyment do wykonania – zwiększmy ten limit i zobaczmy co się stanie:
# echo 100000 > /proc/sys/vm/max_map_count
--------------------------------------------------
$ strace -c ./processing_pipeline 2
Running Experiment_2_thread_per_req
Experiment interrupted - exception thrown: Resource temporarily unavailable
Only 49983 threads created
Waited 3990.99 ms, avg 25056.4 trans/sec
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
30.91 0.000887 0 50007 mmap
30.07 0.000863 0 49995 1 mprotect
19.06 0.000547 0 49983 clone
17.21 0.000494 0 49985 munmap
2.75 0.000079 0 5799 brk
0.00 0.000000 0 6 read
0.00 0.000000 0 8 write
0.00 0.000000 0 7 open
0.00 0.000000 0 7 close
0.00 0.000000 0 7 fstat
0.00 0.000000 0 3 lseek
0.00 0.000000 0 2 rt_sigaction
0.00 0.000000 0 1 rt_sigprocmask
0.00 0.000000 0 7 7 access
0.00 0.000000 0 1 execve
0.00 0.000000 0 1 getrlimit
0.00 0.000000 0 1 arch_prctl
0.00 0.000000 0 348 18 futex
0.00 0.000000 0 1 set_tid_address
0.00 0.000000 0 1 set_robust_list
------ ----------- ----------- --------- --------- ----------------
100.00 0.002870 206170 26 total
Wyników chyba nie muszę komentować. Mamy przyczynę niepowodzenia. Jako ciekawostkę podam stos wywołań, który prowadzi do funkcji mprotect
:
(gdb) bt
#0 0x00007ffff7050777 in mprotect () at ../sysdeps/unix/syscall-template.S:84
#1 0x00007ffff7bc24fa in allocate_stack (...) at allocatestack.c:649
#2 __pthread_create_2_1 (...) at pthread_create.c:539
#3 0x00007ffff78f0dc3 in std::thread::_M_start_thread(...) from /usr/lib/x86_64-linux-gnu/libstdc++.so.6
#4 0x000000000040eabe in std::thread::thread(...)...
...
#8 0x0000000000404705 in Experiment_2_thread_per_req () at main.cpp:92
#9 0x00000000004058ce in main (argc=2, argv=0x7fffffffdcb8) at main.cpp:294
Więc co teraz? Możemy manipulować limitami ale jest to bardzo zła droga w tym przypadku. Nadmierna liczba wątków negatywnie wpływa na wydajność. Trzeba to jakoś ograniczyć programowo. Najprostsze podejście może wyglądać tak:
max_thread_limit = 100
while not done:
thread_num = 0
while thread_num < max_thread_limit:
request = 100k_requests.get_next()
create_thread(RequestProcessor::Process(request))
thread_num++
wait_for_all_threads()
Odpalmy więc nasz program i zobaczmy jakie korzyści daje nam wielowątkowość:
./processing_pipeline 3
Running Experiment_3_thread_per_req_with_throttle for 100 threads
Control result: 6.49994e+10
Total sleep: 18920 ms, # yield: 49800, # requests: 100000
Waited 25767.7 ms, avg 3880.83 trans/sec
WTF??? Wynik gorszy niż w przypadku przetwarzania sekwencyjnego opisanego w poprzednim artykule! Dla przypomnienia:
$ ./processing_pipeline 1
Running Experiment_1_single_threaded
Control result: 6.49994e+10
Total sleep: 18920 ms, # yield: 49800, # requests: 100000
Waited 23612.7 ms, avg 4235 trans/sec
Czyli teraz mamy ponad 25 sekund! Co z tą wielowątkowością?
Otóż powyższy przykład pokazuje jak nieumiejętne korzystanie z tego narzędzia przynosi więcej szkód niż pożytku. Celowo pokazałem jakie problemy z limitami możemy napotkać. Niestety często podczas testów możemy tych problemów nie zauważyć gdyż same limity są dość wysoko ustawione a analiza takich zdarzeń może być trudna. Jak zwykle poinformuje nas o problemach niespecjalnie szczęśliwy klient. Druga sprawa to ślepa wiara w sprawczą moc wielowątkowości. Nasze zrównoleglenie nie pomogło, a przynajmniej nie to naiwne podejście. W kolejnej części zobaczymy co poszło nie tak i gdzie jest problem. Zobaczymy też jak można usprawnić nasz algorytm aby wielowątkowość okazała się skuteczna.