Współbieżność (ang. *concurrency*) to sposób zaplanowania programu tak, by wiele zadań postępowało naprzód w nakładających się odstępach czasu, na przykład gdy jedno czeka na odpowiedź z sieci, inne się wykonuje. Równoległość (*parallelism*) oznacza, że zadania faktycznie biegną jednocześnie na różnych rdzeniach procesora. W Go goroutine dają oba efekty, a w Pythonie wątki przyspieszają głównie zadania czekające na sieć lub dysk, a obciążenie procesora wymaga procesów.
Kiedy aplikacja ma obsłużyć tysiące użytkowników, pobrać dane z kilku usług albo przetworzyć duży plik, prędzej czy później pojawia się pytanie: co może się dziać jednocześnie? Odpowiedź zaczyna się od zrozumienia kilku podobnych, ale różnych pojęć. Poniżej wyjaśniamy współbieżność (concurrency), równoległość (parallelism), wielowątkowość (multithreading) i asynchroniczność, pokazujemy przykłady w Go i Pythonie oraz wyniki pomiarów, które uruchomiliśmy na komputerze z 12 rdzeniami logicznymi.
Jak po polsku nazywa się concurrency i czym różni się od równoległości?
W polskiej literaturze funkcjonuje kilka nazw, a część z nich w praktyce bywa używana zamiennie, dlatego warto je uporządkować:
| Pojęcie po polsku | Odpowiednik angielski | Krótko |
|---|---|---|
| Współbieżność, przetwarzanie współbieżne, programowanie współbieżne | concurrency, concurrent computing | struktura programu: wiele zadań postępuje w nakładających się odstępach czasu |
| Równoległość, obliczenia równoległe, programowanie równoległe | parallelism, parallel computing | zadania faktycznie wykonują się w tej samej chwili, na kilku rdzeniach |
| Wielowątkowość | multithreading | technika: wiele wątków w jednym procesie |
| Wielozadaniowość | multitasking | system operacyjny przełącza zadania lub procesy |
| Asynchroniczność | asynchrony, async/await | nie czekasz bezczynnie na wynik: zaczynasz zadanie i wracasz do niego później |
Polska Wikipedia opisuje przetwarzanie współbieżne jako przetwarzanie „oparte na współistnieniu wielu wątków lub procesów, operujących na współdzielonych danych”. Zwraca też uwagę, że na jednym procesorze wątki są przełączane w krótkich odstępach czasu, a naprawdę jednocześnie działają dopiero na procesorach wielordzeniowych. To najkrótsze wyjaśnienie różnicy.
Najprościej pamiętać dwa zdania z bloga Go, które są dziś standardem: „Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once.” Czyli współbieżność to radzenie sobie z wieloma rzeczami naraz (sposób zbudowania programu), a równoległość to robienie wielu rzeczy naraz (to, co faktycznie dzieje się na procesorze). Program może być współbieżny bez równoległości, na przykład na jednym rdzeniu, i właśnie to pokażemy w pomiarach.
Dlaczego współbieżność jest ważna w programowaniu?
Z trzech powodów:
- Czas oczekiwania. Większość aplikacji webowych dużo czeka na bazę danych, inne usługi i dysk. Gdy zadania są niezależne, nie ma sensu robić ich po kolei. W naszym teście pięć zadań po 0,5 s zajęło po kolei 2,5 s, a współbieżnie 0,5 s.
- Wykorzystanie procesora. Współczesne komputery mają wiele rdzeni, a program jednowątkowy używa jednego. Do obliczeń trzeba je rozdzielić.
- Responsywność. Interfejs i serwer powinny obsługiwać nowe żądania, zamiast zamrażać się na jednym długim zadaniu.
Czym różni się zadanie czekające od zadania liczącego?
To najważniejszy podział, od którego zależy wybór narzędzia:
| I/O-bound (czeka) | CPU-bound (liczy) | |
|---|---|---|
| Co zajmuje czas | sieć, dysk, baza danych | obliczenia procesora |
| Co pomaga | współbieżność (wątki, async, goroutine) | równoległość (wiele rdzeni) |
| Przykład | pobranie kilku stron | przetwarzanie obrazu, duże obliczenia |
Zadanie czekające nie zużywa procesora podczas oczekiwania, więc wystarczy, że w międzyczasie zajmie się czymś innym. Zadanie liczące potrzebuje faktycznie więcej rdzeni.
Czym wielowątkowość różni się od wielozadaniowości i kto za co odpowiada?
Oba pojęcia dotyczą wykonywania wielu rzeczy naraz, ale na różnych poziomach i z różnym podziałem odpowiedzialności.
Wielozadaniowość (multitasking) to zdolność systemu operacyjnego do uruchamiania wielu programów, czyli procesów, w tym samym czasie. Za wszystko odpowiada system: jego planista (scheduler) przydziela każdemu procesowi kawałki czasu procesora i przełącza je tak szybko, że użytkownik widzi, jakby działały jednocześnie. Każdy proces ma własną pamięć, więc jeden nie psuje danych drugiego. Jako programista nie programujesz tego mechanizmu, bo działa zawsze, kiedy uruchamiasz kilka programów (możesz najwyżej ustawić priorytet).
Wielowątkowość (multithreading) to zdolność jednego programu do wykonywania kilku wątków w ramach tego samego procesu. Tu odpowiedzialność jest podzielona: Ty decydujesz, ile wątków utworzyć i co robią, system operacyjny decyduje, kiedy i na którym rdzeniu każdy wątek dostanie czas procesora, a za bezpieczeństwo danych współdzielonych przez wątki, czyli blokady i synchronizację, znów odpowiadasz Ty.
W Pythonie widać to w praktyce. Trzy wątki w jednym programie miały ten sam numer procesu (PID), ale trzy różne identyfikatory wątków. Trzy procesy z ProcessPoolExecutor miały trzy różne PID-y, a żaden nie był procesem nadrzędnym. Wątki współdzielą więc pamięć jednego procesu, a procesy mają własną.
Zobacz, kto decyduje o czym przy poszczególnych rozwiązaniach:
Procesy (wielozadaniowość, multiprocessing) | Wątki systemowe (wielowątkowość) | Goroutine (Go) | asyncio i pętla zdarzeń (event loop) | |
|---|---|---|---|---|
| Kto tworzy jednostkę | Ty lub system (uruchomienie programu) | Ty | Ty (słowo go) | Ty (async def, zadania) |
| Kto przydziela czas procesora | system operacyjny | system operacyjny | środowisko Go na wątkach, które planuje system | pętla zdarzeń, jeden wątek, przełączanie tylko na await |
| Pamięć | osobna dla każdego procesu | wspólna | wspólna | wspólna, ale jeden wątek |
| Kto dba o poprawność danych | Ty (komunikacja między procesami) | Ty (blokady) | Ty (kanały, Mutex) | Ty, ale rzadziej potrzebne, bo wszystko biegnie w jednym wątku |
| Najłatwiej o błąd | koszt przesyłania danych | wyścigi i deadlocki | wyścigi i deadlocki | blokujące wywołanie w pętli |
Co kontrolujesz jako programista, a co robi system operacyjny?
Najłatwiej zrozumieć to, idąc od najniższej warstwy.
Procesy: izolacją zajmuje się system. Gdy uruchamiasz osobny proces, system operacyjny przydziela mu własną pamięć i dba o to, by inne procesy nie mogły jej zepsuć. To dlatego błąd w jednym programie zwykle nie wywraca drugiego. Programista decyduje tylko, ile procesów uruchomić i jak przesłać dane między nimi, a to ma koszt: dane trzeba skopiować do drugiego procesu. Zobaczyliśmy to w pomiarach, gdzie 2000 maleńkich zadań w procesach trwało 0,19 s zamiast 0,02 s po kolei.
Wątki: Ty tworzysz, system szereguje. W wątkach to Ty piszesz, co każdy wątek robi i ile ich jest, ale nie wybierasz, na którym rdzeniu wylądują ani kiedy dostaną procesor. Robi to planista systemu, który może wątek zatrzymać w dowolnej chwili (to właśnie wywłaszczanie). Dlatego dwa wątki mogą wejść sobie w drogę przy zmianie wspólnej zmiennej, a pilnowanie tego spada na Ciebie: blokadami (Lock, Mutex), kanałami albo przez unikanie wspólnych danych.
Goroutine: Ty tworzysz, środowisko Go szereguje, system szereguje wątki. W Go jest dodatkowa warstwa. Goroutine nie są wątkami systemu, tylko lekkimi zadaniami, które środowisko uruchomieniowe Go rozkłada na niewielką liczbę wątków systemowych. Kontrolujesz, co jest goroutine, jak się komunikują i ile wątków może jednocześnie wykonywać kod Go (zmienna GOMAXPROCS). Ta ostatnia kontrola jest realna: przy GOMAXPROCS=1 ten sam kod liczący trwał u nas 0,8 s, a przy 12 rdzeniach 0,2 s. Reszta, czyli szeregowanie wątków na rdzeniach, nadal należy do systemu.
asyncio i pętla zdarzeń: Ty wyznaczasz punkty przełączenia. Tu system operacyjny nie przełącza zadań w ogóle, bo wszystkie biegną w jednym wątku. Zadanie oddaje sterowanie samo, w miejscu, w którym wpiszesz await. Masz więc największą kontrolę, ale też największą odpowiedzialność: jeden time.sleep zamiast await asyncio.sleep zamienił nam 0,5 s w 2,5 s dla całej grupy zadań.
W skrócie: im bliżej systemu operacyjnego, tym więcej on robi za Ciebie (izolacja, szeregowanie), ale tym mniej wiesz, kiedy co się wykona. Im bliżej Twojego kodu (asyncio), tym więcej przewidywalności, ale tym łatwiej samemu zablokować wszystko.
Jak wygląda współbieżność w Go?
Go ma współbieżność wbudowaną w język. Uruchamiasz funkcję w goroutine słowem go, a czekanie na wszystkie zadania realizuje sync.WaitGroup:
func fetch(n int) string {
time.Sleep(500 * time.Millisecond) // udaje czekanie na sieć
return fmt.Sprintf("odpowiedź %d", n)
}
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
fetch(i)
}()
}
wg.Wait()
Pięć goroutine zakończyło pracę po 0,5 s, a wywołanie po kolei zajęło 2,5 s. (Przykład zakłada Go 1.22 lub nowsze, w którym każda iteracja pętli ma własną zmienną i; w starszych wersjach trzeba przekazać i jako argument.) Goroutine to lekka jednostka zarządzana przez środowisko uruchomieniowe Go, które rozkłada je na kilka wątków systemowych. Więcej o samym języku piszemy w artykule o Go (GoLang).
Czy goroutine są równoległe?
To najpopularniejsze pytanie o Go (ma ponad 20 tys. wyświetleń na Stack Overflow) i dobrze pokazuje różnicę z poprzedniej sekcji. Liczbę wątków, które jednocześnie wykonują kod Go, ustawia zmienna GOMAXPROCS. Uruchomiliśmy to samo zadanie liczące (cztery zadania po 600 milionów iteracji) z różnymi wartościami:
| Ustawienie | Zadania I/O (5 × 0,5 s) | Zadania CPU (4 zadania) |
|---|---|---|
| po kolei, bez goroutine | 2,5 s | 0,8 s |
goroutine, GOMAXPROCS=12 (domyślnie) | 0,5 s | 0,2 s |
goroutine, GOMAXPROCS=4 | nie mierzyliśmy | 0,2 s |
goroutine, GOMAXPROCS=1 | 0,5 s | 0,8 s |
Wniosek: przy GOMAXPROCS=1 goroutine nadal przyspieszyły zadania czekające (0,5 s), bo to współbieżność, ale nie przyspieszyły zadań liczących (0,8 s), bo zabrakło równoległości. Dopiero kilka rdzeni skróciło obliczenia do 0,2 s.
Komunikację między goroutine zapewniają kanały (channels), o których piszemy w artykule o pytaniach rekrutacyjnych z Go.
Jak wygląda współbieżność w Pythonie?
Python oferuje kilka narzędzi, a wybór zależy od typu zadania. Poniższe pomiary wykonaliśmy w Pythonie 3.13.7:
| Narzędzie | Zadania I/O (5 × 0,5 s) | Zadania CPU (4 zadania) |
|---|---|---|
| po kolei | 2,5 s | 1,2 s |
wątki (ThreadPoolExecutor) | 0,5 s | 1,2 s |
procesy (ProcessPoolExecutor) | nie mierzyliśmy | 0,4 s |
asyncio | 0,5 s | nie dotyczy |
Kod dla zadań czekających z wątkami i z asyncio:
import asyncio
from concurrent.futures import ThreadPoolExecutor
import time
def fetch(n: int) -> str:
time.sleep(0.5)
return f"odpowiedź {n}"
async def afetch(n: int) -> str:
await asyncio.sleep(0.5)
return f"odpowiedź {n}"
async def main() -> None:
await asyncio.gather(*(afetch(i) for i in range(5)))
with ThreadPoolExecutor(max_workers=5) as pool:
list(pool.map(fetch, range(5))) # ok. 0,5 s
asyncio.run(main()) # ok. 0,5 s
Dla zadań czekających wątki i asyncio dały ten sam wynik (0,5 s). Różnica jest w modelu: wątki wykonują zwykły kod, a asyncio działa w jednej pętli zdarzeń (event loop) i wymaga słów async i await. Praktyczny przykład asyncio w backendzie opisujemy w artykule o FastAPI i równoległych zapytaniach.
Dlaczego wątki nie przyspieszyły obliczeń w Pythonie?
Zobacz drugą kolumnę tabeli: cztery wątki liczyły dokładnie tak długo jak kod po kolei (1,2 s), a cztery procesy 0,4 s. Powodem jest GIL (Global Interpreter Lock), blokada w standardowym interpreterze CPython, która sprawia, że w jednej chwili tylko jeden wątek wykonuje kod Pythona. Dla zadań czekających nie jest to problem, bo wątek podczas czekania oddaje blokadę. Dla zadań liczących wątki nie dają równoległości, więc używa się procesów (multiprocessing, ProcessPoolExecutor), z których każdy ma własny interpreter.
Od Pythona 3.13 istnieje opcjonalna wersja interpretera bez GIL, zwana free threading, w której wątki mogą działać równolegle na wielu rdzeniach. Dokumentacja podaje, że to osobny build (instalatory macOS i Windows mogą go opcjonalnie zainstalować), a GIL może zostać włączony z powrotem po zaimportowaniu rozszerzenia C, które nie obsługuje tej wersji. Standardowa instalacja, której użyliśmy, ma GIL włączony. Tej wersji nie uruchamialiśmy, więc nie podajemy wyników.
Czym różnią się Go i Python we współbieżności?
| Cecha | Go | Python (CPython) |
|---|---|---|
| Model | goroutine zarządzane przez środowisko Go | wątki, procesy, asyncio (osobne mechanizmy) |
| Zadania czekające | goroutine (0,5 s) | wątki lub asyncio (0,5 s) |
| Zadania liczące | goroutine na wielu rdzeniach (0,8 s → 0,2 s) | procesy (1,2 s → 0,4 s), wątki bez zysku (GIL) |
| Komunikacja | kanały i sync | kolejki (queue), asyncio.Queue, blokady |
| Składnia | słowo go przed wywołaniem | async/await albo klasy wątków i procesów |
| Wbudowane wykrywanie wyścigów | go run -race | brak odpowiednika w standardzie |
Uwaga do liczb: w obu językach zadania liczące miały różną liczbę iteracji, więc nie porównujemy czasów między językami, tylko zysk z współbieżności wewnątrz każdego z nich.
Jakie problemy powoduje współbieżność?
Gdy kilka zadań zmienia te same dane, pojawia się wyścig danych (race condition): wynik zależy od tego, które zadanie było szybsze. W Go tysiąc goroutine wykonujących count++ bez synchronizacji zwróciło u nas 952 zamiast 1000, a go run -race wypisał WARNING: DATA RACE. Z sync.Mutex wynik wyniósł 1000.
W Pythonie 4 wątki zwiększające licznik milion razy każdy dały w trzech próbach poprawny wynik bez blokady, ale nie jest to gwarancja: dokumentacja zaleca używać threading.Lock i innych prymitywów synchronizacji zamiast liczyć na zachowanie interpretera. Z Lock wynik zawsze wynosił tyle, ile oczekiwaliśmy. Drugim klasycznym problemem jest deadlock, czyli sytuacja, w której zadania czekają na siebie nawzajem i żadne nie ruszy, jak opisujemy w artykule o Go.
Gdzie współbieżność się nie sprawdza?
Współbieżność nie jest receptą na każdy problem. Poniżej pięć sytuacji, w których nie pomaga albo szkodzi, razem z tym, co zmierzyliśmy.
Dlaczego wątki w Pythonie nie przyspieszają obliczeń?
Przez GIL. Cztery zadania liczące trwały po kolei 1,2 s i w czterech wątkach też 1,2 s, bo w jednej chwili kod Pythona wykonuje tylko jeden wątek. Dopiero cztery procesy skróciły czas do 0,4 s. Wątki w Pythonie są dobre do zadań czekających, a do liczenia potrzebujesz procesów (albo specjalnej wersji interpretera bez GIL, o której piszemy wyżej).
Kiedy współbieżność spowalnia program?
Gdy zadania są tak małe, że koszt ich uruchomienia przewyższa korzyść. 2000 maleńkich zadań (sum(range(1000))) trwało po kolei 0,02 s, w wątkach 0,03 s, a w procesach 0,19 s, czyli około dziesięć razy dłużej. Każdy wątek lub proces trzeba utworzyć, zaplanować i skomunikować z resztą programu, a przy procesach dochodzi kopiowanie danych. Zasada: współbieżność opłaca się, gdy jedno zadanie trwa wyraźnie dłużej niż jego uruchomienie.
Co się dzieje, gdy zadanie blokuje pętlę zdarzeń?
Wszystko się zatrzymuje. W asyncio pięć zadań po 0,5 s trwało 0,5 s z await asyncio.sleep, ale 2,5 s z blokującym time.sleep. W JavaScripcie pętla while zajmująca wątek na 500 ms opóźniła setTimeout ustawiony na 100 ms do 502 ms. Dlatego w kodzie asynchronicznym nie wywołujesz bez await długich operacji: ani odczytu z sieci, ani obliczeń. Ciężkie obliczenia przenosi się do procesów lub wątków roboczych.
Co, gdy zadania zależą od siebie?
Wtedy nie ma czego rozdzielić. Jeśli każdy krok potrzebuje wyniku poprzedniego, kolejne wątki nie przyspieszą całości. W ogólnym przypadku zysk ogranicza ta część programu, której nie da się zrównoleglić (to prawo Amdahla, którego nie mierzyliśmy). Dlatego przed dodaniem współbieżności warto zastanowić się, które fragmenty są naprawdę niezależne.
Dlaczego współbieżny kod jest trudniejszy w utrzymaniu?
Bo błędy zależą od kolejności zdarzeń, a ta bywa losowa. W Go tysiąc goroutine zwiększających wspólny licznik bez synchronizacji dało 952 zamiast 1000, i za każdym razem inny wynik. W takim kodzie błąd potrafi pokazać się raz na sto uruchomień, a testy zwykle go nie wyłapią. Dochodzi do tego deadlock, czyli sytuacja, w której zadania czekają na siebie nawzajem. Jeśli możesz uniknąć współdzielenia danych, na przykład przekazując wyniki kanałem, kod będzie prostszy.
Osobną kategorią są systemy czasu rzeczywistego, w których trzeba dotrzymać terminu co do milisekundy. Zwykły system operacyjny, jak Windows, macOS czy standardowy Linux, nie daje takich gwarancji, więc w takich projektach używa się wyspecjalizowanych rozwiązań. Tego obszaru nie testowaliśmy.
| Sytuacja | Skutek | Nasz pomiar |
|---|---|---|
| Wątki w Pythonie do obliczeń | brak przyspieszenia (GIL) | po kolei 1,2 s, wątki 1,2 s, procesy 0,4 s |
| Bardzo małe zadania | narzut większy niż zysk | po kolei 0,02 s, wątki 0,03 s, procesy 0,19 s |
| Blokujące wywołanie w pętli zdarzeń | zatrzymuje wszystkie zadania | asyncio: 0,5 s zamiast 2,5 s; JS: 502 ms zamiast 100 ms |
| Zadania zależne od siebie | nie ma czego rozdzielić | nie mierzone |
| Wspólne dane bez synchronizacji | wyścigi, deadlocki | Go: 952 zamiast 1000 |
Jak wybrać narzędzie do współbieżności?
| Twoje zadanie | Go | Python |
|---|---|---|
| Wiele zapytań HTTP lub do bazy | goroutine | asyncio lub wątki |
| Ciężkie obliczenia | goroutine na wielu rdzeniach | procesy |
| Prosty skrypt, mało zadań | zwykły kod po kolei | zwykły kod po kolei |
| Serwer obsługujący wielu klientów | goroutine na żądanie | asyncio (np. FastAPI) lub wiele procesów |
Zanim dodasz współbieżność, zmierz, gdzie program traci czas. Często wystarczy lepszy algorytm albo zapamiętanie wyniku, a każda warstwa współbieżności zwiększa złożoność i ryzyko błędów.
A co z JavaScriptem?
JavaScript w przeglądarce i w Node.js wykonuje kod w jednym wątku, a asynchroniczność opiera na pętli zdarzeń (event loop). Pętla bierze kolejne zadania z kolejek i uruchamia je dopiero wtedy, gdy kod synchroniczny się skończy. Działanie kolejek i mikrozadań na uruchomionych przykładach opisujemy w tekście o event loopie w JavaScript. Dlatego async i await w JavaScripcie przypominają asyncio z Pythona, a nie goroutine z Go.
Kolejność widać na krótkim przykładzie uruchomionym w Node.js 22:
console.log("1 kod synchroniczny");
setTimeout(() => console.log("4 setTimeout 0 ms (zadanie)"), 0);
Promise.resolve().then(() => console.log("3 promise (mikrozadanie)"));
console.log("2 koniec kodu synchronicznego");
Wynik to 1, 2, 3, 4: najpierw cały kod synchroniczny, potem obietnica (mikrozadanie), a dopiero na końcu setTimeout z zerowym opóźnieniem. Zadanie blokujące pętlę zatrzymuje wszystko, tak jak w asyncio: setTimeout ustawiony na 100 ms odpalił u nas dopiero po 502 ms, bo pętla while zajmowała wątek przez 500 ms. Podstawy asynchroniczności w JavaScripcie opisujemy w artykule o funkcjach zwrotnych.
Jak przygotować się do rozmowy o współbieżności?
Na rozmowach często pada pytanie o różnicę między współbieżnością a równoległością, GIL, wątkami i procesami oraz o wyścig danych. Przykłady kodu i odpowiedzi znajdziesz w artykułach z serii: pytania rekrutacyjne z Go i pytania rekrutacyjne z Pythona. Jeśli dopiero uczysz się Pythona, ścieżkę nauki z mentorem znajdziesz w kursie Python online.


