Skip to content

Split View: 동시성 vs 병렬성 — 다루기와 실제로 하기의 차이

|

동시성 vs 병렬성 — 다루기와 실제로 하기의 차이

들어가며 — 자주 뒤섞이는 두 단어

"동시성(concurrency)"과 "병렬성(parallelism)"은 개발자들이 가장 자주 혼용하는 단어 쌍입니다. 둘 다 "여러 일이 한꺼번에 일어난다"는 느낌을 주기 때문입니다. 하지만 이 둘은 다른 개념이고, 그 차이를 흐릿하게 두면 성능 문제도, 버그도 엉뚱한 방향에서 찾게 됩니다.

Go 언어의 설계자 Rob Pike가 남긴 정의가 가장 명쾌합니다.

동시성은 여러 일을 다루는(dealing with) 것이고, 병렬성은 여러 일을 동시에 하는(doing) 것이다.

이 한 문장에 핵심이 다 들어 있습니다. 동시성은 구조에 관한 것입니다. 여러 작업을 어떻게 쪼개고 조율할지의 문제입니다. 병렬성은 실행에 관한 것입니다. 실제로 같은 순간에 여러 계산이 진행되는지의 문제입니다. 동시성은 병렬성 없이도 존재할 수 있고(코어가 하나뿐인 컴퓨터에서도 여러 작업을 번갈아 다룰 수 있으니까요), 병렬성은 동시성을 실현하는 하나의 방법일 뿐입니다.

이 글은 이 구분에서 출발해 스레드와 이벤트 루프, 경쟁 조건과 락, 데드락과 원자적 연산까지, 동시 프로그래밍의 핵심 지형을 정리합니다. 이벤트 루프가 실제로 어떻게 작업을 번갈아 처리하는지 눈으로 보고 싶다면 메시지 큐 놀이터의 asyncio 탭을, 여러 연산이 병렬로 흐르는 계산 그래프를 보고 싶다면 신경망 실험실을 함께 열어보세요.

카페 비유로 이해하기

Rob Pike의 정의를 일상 장면으로 옮겨보면 이렇습니다.

카페에 바리스타가 한 명 있습니다. 주문이 세 개 들어왔습니다. 이 바리스타는 첫 주문의 에스프레소를 내리는 동안 두 번째 주문의 우유를 데우고, 그 사이 세 번째 주문의 컵을 준비합니다. 한 사람이지만 세 주문을 다루고 있습니다. 이것이 동시성입니다. 실제로 같은 순간에 두 손으로 두 가지를 하는 게 아니라, 기다리는 시간을 활용해 작업 사이를 오가는 것입니다.

이제 바리스타를 세 명 고용합니다. 각자 한 주문씩 맡아 진짜로 동시에 만듭니다. 이것이 병렬성입니다. 세 잔이 같은 순간에 실제로 만들어집니다.

핵심 통찰은 이것입니다. 동시성은 문제를 독립적으로 실행 가능한 조각으로 구조화하는 방법이고, 병렬성은 그 조각들을 실제로 여러 일꾼에게 나눠 동시에 실행하는 것입니다. 잘 설계된 동시적 구조는 일꾼이 하나면 번갈아 처리하고, 일꾼이 여럿이면 자연스럽게 병렬로 확장됩니다.

스레드 vs 이벤트 루프 — 두 가지 실행 모델

동시성을 코드로 구현하는 대표적인 방법이 두 가지 있습니다. 스레드 기반 모델과 이벤트 루프(async) 모델입니다.

스레드 모델은 운영체제가 여러 실행 흐름(스레드)을 만들고, 스케줄러가 이들을 코어에 배정합니다. 코어가 여러 개면 스레드들이 진짜 병렬로 실행됩니다. 각 스레드는 자기 스택을 가지고 독립적으로 진행하며, 운영체제가 언제든 한 스레드를 멈추고 다른 스레드로 전환할 수 있습니다(선점형 스케줄링).

이벤트 루프 모델은 단 하나의 스레드가 있고, 그 안에서 여러 작업(코루틴, 태스크)을 번갈아 처리합니다. 앞의 카페 바리스타와 같습니다. 작업이 I/O를 기다리는 순간(await) 그 작업을 잠시 내려놓고 다른 작업을 진행합니다. 전환 지점을 프로그래머가 명시적으로 표시한다는 점에서 협력형 스케줄링입니다.

스레드 모델 (선점형)
  스레드 A ████░░░░████░░░░       OS가 임의 시점에 전환
  스레드 B ░░░░████░░░░████       여러 코어면 진짜 병렬 가능

이벤트 루프 모델 (협력형)
  단일 스레드  A─await─B─await─A─await─C ...
               작업이 스스로 양보하는 지점에서만 전환

두 모델의 근본적 차이는 "전환이 언제 일어나는가"입니다. 스레드는 운영체제가 아무 때나 끼어들어 전환합니다. 그래서 진짜 병렬 실행이 가능하지만, 바로 그 때문에 공유 데이터를 다룰 때 위험합니다. 이벤트 루프는 오직 await 같은 명시적 지점에서만 전환하므로 예측 가능하지만, 한 작업이 양보하지 않고 계산을 붙들면 나머지 전부가 멈춥니다.

경쟁 조건과 데이터 레이스

동시 프로그래밍이 어려운 이유의 핵심에 **경쟁 조건(race condition)**이 있습니다. 여러 실행 흐름이 공유 자원에 접근하는데, 그 결과가 실행 순서(타이밍)에 따라 달라지는 상황입니다.

가장 고전적인 예가 카운터 증가입니다. counter += 1이라는 한 줄은 사실 세 단계로 이뤄집니다.

1. counter 값을 읽는다      (예: 10)
2. 그 값에 1을 더한다        (11)
3. 결과를 counter에 쓴다     (11)

두 스레드가 동시에 이 세 단계를 실행하면 다음과 같은 일이 벌어질 수 있습니다.

스레드 A: 읽기(10) ....... 더하기(11) 쓰기(11)
스레드 B: ......... 읽기(10) ....... 더하기(11) 쓰기(11)
결과: counter = 11  (두 번 증가시켰는데 11!)

두 번 더했는데 결과는 11입니다. 하나의 증가가 사라졌습니다. 이렇게 여러 스레드가 동기화 없이 같은 메모리에 접근하고 그중 하나 이상이 쓰기를 하는 상황을 특히 **데이터 레이스(data race)**라고 부릅니다. 데이터 레이스는 경쟁 조건의 한 종류이며, 많은 언어에서 정의되지 않은 동작(undefined behavior)으로 취급됩니다.

파이썬으로 이 문제를 재현하면 이렇습니다.

import threading

counter = 0

def increment():
    global counter
    for _ in range(100_000):
        counter += 1   # 원자적이지 않음 — 레이스 발생 가능

threads = [threading.Thread(target=increment) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(counter)  # 400000이 아니라 그보다 작은 값이 나올 수 있음

경쟁 조건의 무서운 점은 재현성이 낮다는 것입니다. 대부분의 경우 우연히 순서가 맞아 정상 동작하다가, 부하가 높거나 타이밍이 특정 조건일 때만 어긋납니다. 그래서 테스트에서는 안 나타나다가 운영 환경에서만 터지는 악명 높은 버그가 됩니다.

락과 뮤텍스 — 순서를 강제하기

경쟁 조건을 막는 기본 도구가 락(lock), 그중에서도 상호 배제를 뜻하는 **뮤텍스(mutex, mutual exclusion)**입니다. 아이디어는 단순합니다. 공유 자원을 만지는 코드 구간(임계 구역, critical section)에는 한 번에 하나의 실행 흐름만 들어가게 합니다.

import threading

counter = 0
lock = threading.Lock()

def increment():
    global counter
    for _ in range(100_000):
        with lock:          # 한 번에 한 스레드만 이 블록 안에
            counter += 1

# 이제 결과는 항상 400000

with lock 블록에 들어가려면 락을 얻어야 하고, 한 스레드가 락을 쥐고 있으면 다른 스레드는 그것이 풀릴 때까지 기다립니다. 덕분에 읽기-더하기-쓰기 세 단계가 쪼개지지 않고 원자적으로 실행됩니다.

락은 강력하지만 공짜가 아닙니다. 세 가지 대가가 따릅니다.

  • 성능: 락을 얻고 푸는 데 비용이 들고, 여러 스레드가 같은 락을 두고 다투면(경합, contention) 줄 서서 기다리느라 병렬성이 사라집니다.
  • 입도(granularity) 선택: 락을 넓게 잡으면(coarse-grained) 안전하지만 병렬성이 줄고, 좁게 잡으면(fine-grained) 병렬성은 좋지만 복잡하고 버그가 생기기 쉽습니다.
  • 데드락 위험: 락을 여러 개 쓰면 서로 상대의 락을 기다리며 영원히 멈추는 교착 상태가 생길 수 있습니다.

데드락과 식사하는 철학자

**데드락(deadlock, 교착 상태)**은 둘 이상의 실행 흐름이 서로가 쥔 자원을 기다리며 아무도 진행하지 못하는 상태입니다. 이를 가장 잘 보여주는 고전 문제가 Edsger Dijkstra가 제시한 **식사하는 철학자(dining philosophers)**입니다.

원형 식탁에 철학자 다섯 명이 앉아 있습니다. 각자 앞에는 스파게티 접시가 있고, 철학자들 사이에는 포크가 하나씩, 총 다섯 개 놓여 있습니다. 스파게티를 먹으려면 왼쪽과 오른쪽 포크 두 개가 모두 필요합니다.

        철학자0
     포크4      포크0
  철학자4          철학자1
     포크3      포크1
        철학자3  포크2  철학자2

  각 철학자는 양옆 포크 2개가 있어야 식사 가능

이제 모든 철학자가 동시에 "먼저 왼쪽 포크를 집고, 그다음 오른쪽 포크를 집는다"는 똑같은 규칙을 따른다고 합시다. 모두가 동시에 왼쪽 포크를 집으면, 다섯 개의 포크가 모두 한 손씩 잡힌 상태가 됩니다. 이제 모두가 오른쪽 포크를 집으려 하지만, 오른쪽 포크는 옆 사람의 왼손에 있습니다. 아무도 두 번째 포크를 얻지 못하고, 아무도 첫 번째 포크를 놓지 않습니다. 영원한 교착입니다.

데드락이 성립하려면 네 가지 조건(Coffman 조건)이 동시에 만족되어야 합니다.

  • 상호 배제: 자원을 한 번에 하나만 쓸 수 있다(포크는 공유 불가).
  • 점유하며 대기: 자원을 쥔 채로 다른 자원을 기다린다(왼쪽 포크를 쥐고 오른쪽을 기다림).
  • 비선점: 남이 쥔 자원을 강제로 뺏을 수 없다.
  • 순환 대기: 대기의 사슬이 원형으로 이어진다.

이 중 하나만 깨도 데드락을 막을 수 있습니다. 대표적인 해법이 순환 대기를 깨는 것입니다. 예를 들어 포크에 번호를 매기고 "항상 번호가 낮은 포크를 먼저 집는다"는 규칙을 두면, 마지막 철학자만 반대 순서로 집게 되어 순환이 끊어지고 데드락이 사라집니다.

원자적 연산 — 락 없이 안전하게

락은 무겁습니다. 단순히 카운터 하나를 안전하게 올리려고 락을 잡았다 푸는 것은 과합니다. 그래서 하드웨어가 직접 지원하는 **원자적 연산(atomic operation)**이 있습니다.

원자적 연산은 CPU가 "쪼갤 수 없는 하나의 동작"으로 보장하는 연산입니다. 앞서 본 읽기-더하기-쓰기 세 단계를, 중간에 다른 스레드가 끼어들 수 없는 단일 명령으로 실행합니다. 대표적인 것이 **CAS(Compare-And-Swap)**입니다.

CAS(주소, 기대값, 새값):
    만약 *주소 == 기대값이면
        *주소 = 새값 으로 바꾸고 성공 반환
    아니면
        아무것도 안 하고 실패 반환
    — 이 전체가 원자적으로 일어남

CAS를 쓰면 락 없이도 안전한 증가를 만들 수 있습니다. "현재 값을 읽고, 그것이 여전히 그대로면 +1한 값으로 바꾼다. 그 사이 누가 바꿨으면 실패하니 다시 시도한다"는 방식입니다. 이런 접근을 락-프리(lock-free) 프로그래밍이라고 부릅니다.

원자적 연산의 장점은 락의 오버헤드와 데드락 위험이 없다는 것입니다. 하지만 만능은 아닙니다. 카운터나 플래그처럼 단순한 단일 값에는 훌륭하지만, 여러 자료구조를 한꺼번에 일관되게 바꿔야 하는 복잡한 경우에는 원자적 연산만으로 표현하기 어렵고, 잘못 쓰면 오히려 미묘한 버그(예: ABA 문제)를 부릅니다. 그래서 실무에서는 단순한 경우엔 원자적 연산, 복잡한 경우엔 락으로 역할을 나눕니다.

언제 async가 이기고, 언제 스레드가 이기는가

이제 가장 실용적인 질문입니다. 동시성이 필요할 때 async(이벤트 루프)를 쓸 것인가, 스레드(또는 멀티프로세싱)를 쓸 것인가? 답은 작업의 성격에 달려 있습니다.

작업은 크게 두 부류로 나뉩니다.

  • I/O 바운드: 대부분의 시간을 무언가를 기다리는 데 쓰는 작업. 네트워크 응답, 디스크 읽기, 데이터베이스 쿼리. CPU는 거의 놀고 있습니다.
  • CPU 바운드: 대부분의 시간을 실제 계산에 쓰는 작업. 이미지 처리, 암호화, 수치 시뮬레이션. CPU가 쉴 틈 없이 돕니다.

이 구분이 선택을 결정합니다.

I/O 바운드에는 async가 이깁니다. 기다리는 시간이 많으므로, 한 스레드가 그 기다림의 틈에 다른 작업을 처리하면 됩니다. 스레드를 수천 개 만드는 것보다 이벤트 루프 하나로 수천 개의 연결을 다루는 편이 메모리도 훨씬 적게 쓰고 전환 비용도 낮습니다. 웹 서버, 프록시, 크롤러처럼 "많이 기다리는" 작업이 여기에 딱 맞습니다.

import asyncio

async def fetch(name, delay):
    await asyncio.sleep(delay)   # 기다리는 동안 다른 코루틴 실행
    return name

async def main():
    # 세 작업을 동시에 — 총 대기 시간이 아니라 최댓값에 가깝게
    results = await asyncio.gather(
        fetch("a", 2), fetch("b", 1), fetch("c", 3),
    )
    print(results)

asyncio.run(main())

CPU 바운드에는 스레드(더 정확히는 여러 프로세스)가 필요합니다. 계산은 양보할 틈이 없으므로 이벤트 루프에서는 한 작업이 CPU를 붙들면 나머지가 다 멈춥니다. 진짜 병렬 실행이 필요하고, 그러려면 여러 코어에 작업을 분산해야 합니다. 여기서 파이썬 특유의 함정이 하나 있습니다. CPython에는 **GIL(Global Interpreter Lock)**이 있어서, 여러 스레드가 있어도 한 번에 하나의 스레드만 파이썬 바이트코드를 실행합니다. 그래서 파이썬에서 CPU 바운드 작업을 진짜 병렬로 돌리려면 스레드가 아니라 멀티프로세싱(여러 프로세스)을 써야 합니다.

정리하면 이렇습니다.

상황좋은 선택이유
많이 기다리는 I/O (네트워크, 디스크)async / 이벤트 루프기다림의 틈을 재활용, 가벼움
무거운 계산 (CPU 집약)멀티프로세싱 / 여러 코어진짜 병렬 실행 필요
I/O인데 async 라이브러리가 없음스레드 풀블로킹 호출을 스레드로 격리
파이썬에서 CPU 병렬멀티프로세싱GIL 때문에 스레드로는 병렬 안 됨

흔한 오해와 함정

동시 프로그래밍에서 자주 걸리는 지점들을 짚습니다.

  • "async가 항상 빠르다"는 오해: async는 I/O 바운드에서만 이깁니다. CPU 바운드 작업을 async로 감싸면 이벤트 루프가 막혀 오히려 더 느려집니다.
  • 이벤트 루프 안의 블로킹 호출: 단일 스레드 이벤트 루프에서 동기 블로킹 함수(일반 time.sleep, 블로킹 DB 드라이버 등)를 부르면 루프 전체가 멈춥니다. async 대응 라이브러리를 쓰거나 별도 스레드로 넘겨야 합니다.
  • "스레드를 많이 만들면 빨라진다"는 오해: 스레드는 각자 스택 메모리를 쓰고 전환 비용이 있습니다. 코어 수를 훨씬 넘는 스레드는 오히려 전환 오버헤드로 느려집니다.
  • 락을 넓게 잡아 병렬성을 죽이기: 안전하다고 임계 구역을 크게 잡으면 사실상 순차 실행이 되어 멀티코어의 이점이 사라집니다.
  • 경쟁 조건을 "가끔 나는 버그"로 방치: 재현이 어렵다고 넘기면 운영 환경에서 데이터 손상으로 돌아옵니다. 공유 상태에는 반드시 동기화를 설계해야 합니다.

마치며

동시성과 병렬성은 비슷해 보이지만 층위가 다릅니다. 동시성은 여러 일을 다루도록 구조화하는 것이고, 병렬성은 그 일들을 실제로 여러 코어에서 동시에 실행하는 것입니다. 잘 설계된 동시적 프로그램은 코어가 하나면 번갈아 처리하고, 코어가 여럿이면 자연스럽게 병렬로 확장됩니다.

그리고 이 구조를 안전하게 만드는 것이 동기화의 기술입니다. 경쟁 조건을 락이나 원자적 연산으로 막고, 데드락을 순서 규칙으로 피하고, 작업의 성격(I/O냐 CPU냐)에 맞춰 async와 멀티프로세싱을 고릅니다. 이 지도를 머릿속에 두면, "왜 느린가"와 "왜 가끔 틀리는가"라는 두 종류의 문제를 훨씬 정확한 곳에서 찾을 수 있습니다.

이벤트 루프가 작업 사이를 어떻게 오가는지 메시지 큐 놀이터의 asyncio 탭에서 직접 확인하고, 여러 연산이 병렬로 흐르는 계산 그래프를 신경망 실험실에서 살펴보세요.

참고 자료

Concurrency vs Parallelism — Dealing With vs Doing Many Things

Introduction — Two Words That Keep Getting Mixed Up

"Concurrency" and "parallelism" are the pair of words developers confuse most often, because both give the impression that "several things are happening at once." But they are different concepts, and leaving the difference fuzzy sends you hunting for both performance problems and bugs in the wrong direction.

The clearest definition comes from Rob Pike, designer of the Go language.

Concurrency is about dealing with many things at once. Parallelism is about doing many things at once.

That one sentence holds the whole point. Concurrency is about structure — how you break work into pieces and coordinate them. Parallelism is about execution — whether multiple computations actually run at the same instant. Concurrency can exist without parallelism (even a single-core machine can deal with several tasks by interleaving them), and parallelism is just one way to realize a concurrent design.

This post starts from that distinction and maps out the core terrain of concurrent programming: threads and event loops, race conditions and locks, deadlock and atomic operations. If you want to watch an event loop actually interleave tasks, open the asyncio tab in the Message Queue Playground; to see a computation graph where many operations flow in parallel, open the Neural Net Lab alongside.

Understanding It Through a Café

Let us move Pike's definition into an everyday scene.

A café has one barista. Three orders come in. While pulling the espresso for the first order, the barista steams milk for the second, and in between prepares the cup for the third. One person, but dealing with three orders. That is concurrency: not literally doing two things with two hands at the same instant, but moving between tasks by using the waiting time.

Now hire three baristas. Each takes one order and truly makes it at the same time. That is parallelism. Three drinks are genuinely being made at the same instant.

The key insight is this. Concurrency is a way of structuring a problem into independently executable pieces; parallelism is actually distributing those pieces across multiple workers to run at the same time. A well-designed concurrent structure interleaves when there is one worker and scales out to run in parallel when there are many.

Threads vs Event Loops — Two Execution Models

There are two dominant ways to implement concurrency in code: the thread-based model and the event loop (async) model.

The thread model has the operating system create multiple execution flows (threads), and a scheduler assigns them to cores. With multiple cores, threads run in true parallel. Each thread has its own stack and proceeds independently, and the OS can pause one thread and switch to another at any time (preemptive scheduling).

The event loop model has a single thread that interleaves multiple tasks (coroutines, tasks) within it. It is exactly like the café barista. The moment a task waits on I/O (await), it is set aside and another task proceeds. Because the programmer marks the switch points explicitly, this is cooperative scheduling.

Thread model (preemptive)
  Thread A ████░░░░████░░░░       OS switches at arbitrary points
  Thread B ░░░░████░░░░████       true parallelism with multiple cores

Event loop model (cooperative)
  single thread  A-await-B-await-A-await-C ...
                 switches only where a task yields on its own

The fundamental difference between the two models is "when does the switch happen." Threads are switched by the OS at any moment. That enables true parallel execution, but for exactly that reason it is dangerous when touching shared data. The event loop switches only at explicit points like await, so it is predictable — but if one task refuses to yield and holds the CPU with computation, everything else stalls.

Race Conditions and Data Races

At the heart of why concurrent programming is hard sits the race condition: multiple execution flows access a shared resource, and the outcome depends on the order (timing) in which they run.

The most classic example is incrementing a counter. The single line counter += 1 is actually three steps.

1. read the value of counter    (say, 10)
2. add 1 to that value          (11)
3. write the result to counter  (11)

If two threads execute these three steps at the same time, this can happen:

Thread A: read(10) ....... add(11) write(11)
Thread B: ........ read(10) ....... add(11) write(11)
result: counter = 11  (incremented twice, but 11!)

Two additions, but the result is 11. One increment vanished. This situation — multiple threads accessing the same memory without synchronization, at least one of them writing — is specifically called a data race. A data race is one kind of race condition, and in many languages it is treated as undefined behavior.

Reproducing this in Python:

import threading

counter = 0

def increment():
    global counter
    for _ in range(100_000):
        counter += 1   # not atomic — a race can occur

threads = [threading.Thread(target=increment) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(counter)  # may print less than 400000

What makes race conditions frightening is their low reproducibility. Most of the time the order happens to line up and everything works, and it goes wrong only under high load or a specific timing. So it becomes the infamous kind of bug that never shows up in tests and only blows up in production.

Locks and Mutexes — Enforcing Order

The basic tool for preventing race conditions is the lock, and specifically the mutex (mutual exclusion). The idea is simple: allow only one execution flow at a time into the code region that touches the shared resource (the critical section).

import threading

counter = 0
lock = threading.Lock()

def increment():
    global counter
    for _ in range(100_000):
        with lock:          # only one thread inside this block at a time
            counter += 1

# now the result is always 400000

To enter the with lock block you must acquire the lock, and while one thread holds it, others wait until it is released. As a result the three steps of read-add-write execute atomically, without being split apart.

Locks are powerful but not free. They come with three costs.

  • Performance: acquiring and releasing a lock has a cost, and when multiple threads fight over the same lock (contention), parallelism disappears as they queue up and wait.
  • Granularity choice: a coarse-grained lock is safe but reduces parallelism; a fine-grained lock gives good parallelism but is complex and bug-prone.
  • Deadlock risk: with multiple locks, you can create a deadlock where each waits forever for the other's lock.

Deadlock and the Dining Philosophers

A deadlock is a state where two or more execution flows each wait for a resource the other holds, and no one can make progress. The classic problem that best illustrates it is the dining philosophers, posed by Edsger Dijkstra.

Five philosophers sit at a round table. In front of each is a plate of spaghetti, and between the philosophers lies one fork each — five in total. To eat, a philosopher needs both the left and right forks.

        philosopher0
     fork4       fork0
  philosopher4        philosopher1
     fork3       fork1
        philosopher3  fork2  philosopher2

  each philosopher needs both adjacent forks to eat

Now suppose every philosopher follows the same rule: "first pick up the left fork, then pick up the right fork." If everyone picks up the left fork at the same time, all five forks are each held by one hand. Now everyone tries to pick up the right fork, but the right fork is in the neighbor's left hand. No one gets a second fork, and no one puts down their first. Deadlock forever.

For a deadlock to arise, four conditions (the Coffman conditions) must all hold simultaneously.

  • Mutual exclusion: a resource can be used by only one at a time (forks cannot be shared).
  • Hold and wait: holding one resource while waiting for another (holding the left fork, waiting for the right).
  • No preemption: you cannot forcibly take a resource someone else holds.
  • Circular wait: the chain of waiting forms a cycle.

Breaking just one of these prevents deadlock. A classic solution is to break the circular wait. For example, number the forks and impose the rule "always pick up the lower-numbered fork first." Then only the last philosopher picks up in the opposite order, the cycle is broken, and the deadlock disappears.

Atomic Operations — Safe Without Locks

Locks are heavy. Grabbing and releasing a lock just to safely bump a single counter is overkill. So there are atomic operations, supported directly by the hardware.

An atomic operation is one the CPU guarantees as "a single, indivisible action." It executes the read-add-write we saw earlier as one instruction that no other thread can interrupt mid-way. The most representative one is CAS (Compare-And-Swap).

CAS(address, expected, new):
    if *address == expected:
        set *address = new and return success
    else:
        do nothing and return failure
    — all of this happens atomically

With CAS you can build a safe increment without a lock: "read the current value; if it is still the same, swap it for value + 1; if someone changed it in the meantime, we fail, so retry." This approach is called lock-free programming.

The advantage of atomic operations is that they carry no lock overhead and no deadlock risk. But they are not a cure-all. They are excellent for a simple single value like a counter or a flag, but in complex cases where several data structures must change consistently all at once, atomic operations alone are hard to express, and misused they invite subtle bugs (such as the ABA problem). So in practice you split the roles: atomic operations for simple cases, locks for complex ones.

When Async Wins, and When Threads Win

Now the most practical question. When you need concurrency, do you use async (an event loop) or threads (or multiprocessing)? The answer depends on the nature of the work.

Work falls broadly into two categories.

  • I/O-bound: work that spends most of its time waiting for something. Network responses, disk reads, database queries. The CPU is mostly idle.
  • CPU-bound: work that spends most of its time on actual computation. Image processing, encryption, numerical simulation. The CPU never rests.

This distinction decides the choice.

For I/O-bound work, async wins. Because there is a lot of waiting, a single thread can handle other tasks in the gaps of that waiting. Handling thousands of connections with one event loop uses far less memory and has lower switching cost than creating thousands of threads. "Lots of waiting" work like web servers, proxies, and crawlers fits here perfectly.

import asyncio

async def fetch(name, delay):
    await asyncio.sleep(delay)   # another coroutine runs during the wait
    return name

async def main():
    # three tasks concurrently — close to the max, not the sum, of the waits
    results = await asyncio.gather(
        fetch("a", 2), fetch("b", 1), fetch("c", 3),
    )
    print(results)

asyncio.run(main())

For CPU-bound work, you need threads — or more precisely, multiple processes. Computation offers no gap to yield, so on an event loop, one task holding the CPU stalls the rest. You need true parallel execution, which means distributing work across multiple cores. Here lies a Python-specific trap. CPython has the GIL (Global Interpreter Lock), so even with multiple threads, only one thread executes Python bytecode at a time. To run CPU-bound work in true parallel in Python, you therefore use multiprocessing (multiple processes), not threads.

In summary:

SituationGood choiceWhy
Lots of waiting I/O (network, disk)async / event loopreuses the gaps of waiting, lightweight
Heavy computation (CPU-intensive)multiprocessing / multiple coresneeds true parallel execution
I/O but no async library availablethread poolisolates the blocking call in a thread
CPU parallelism in Pythonmultiprocessingthreads don't parallelize due to the GIL

Common Misconceptions and Pitfalls

Points people trip over in concurrent programming:

  • The "async is always faster" myth: async only wins for I/O-bound work. Wrapping CPU-bound work in async blocks the event loop and makes it slower.
  • Blocking calls inside the event loop: calling a synchronous blocking function (plain time.sleep, a blocking DB driver, etc.) on a single-threaded event loop stalls the whole loop. Use an async-capable library or offload it to a separate thread.
  • The "more threads means faster" myth: threads each consume stack memory and have switching costs. Far more threads than cores actually slow things down through switching overhead.
  • Killing parallelism with a coarse lock: making the critical section large "to be safe" effectively serializes execution and erases the benefit of multiple cores.
  • Leaving a race condition as an "occasional bug": dismissing it because it is hard to reproduce comes back as data corruption in production. Shared state always needs a synchronization design.

Conclusion

Concurrency and parallelism look similar but sit at different levels. Concurrency is structuring work so it can be dealt with; parallelism is actually executing that work at the same time across multiple cores. A well-designed concurrent program interleaves with one core and scales out naturally to parallel with many.

And what makes that structure safe is the craft of synchronization: preventing race conditions with locks or atomics, avoiding deadlock with ordering rules, and choosing between async and multiprocessing according to the nature of the work (I/O or CPU). Keep this map in mind and you can hunt the two distinct classes of problem — "why is it slow" and "why is it occasionally wrong" — in far more accurate places.

Watch how an event loop moves between tasks in the asyncio tab of the Message Queue Playground, and explore a computation graph where operations flow in parallel in the Neural Net Lab.

References