CS for Building Good Services — Relearning Textbook Ideas by Measuring
The Gaps Are Between Instructions, awaits and Statements
한국어 원문으로 표시합니다.
한 줄 요약
경쟁 조건은 "스레드가 여럿일 때" 생기는 것이 아니라 "읽은 값과 쓰는 값 사이에 남이 끼어들 틈이 있을 때" 생깁니다. 스레드에서는 그 틈이 바이트코드 명령 사이에, asyncio 에서는 await 마다, 데이터베이스에서는 SELECT 와 UPDATE 사이에 있습니다. 틈을 없애거나(원자적 연산) 틈 전체를 한 번에 한 작업만 지나가게(락) 해야 합니다.
왜 이게 필요했나
'운영체제' 코스의 동시성 모듈은 스레드 넷이 같은 값을 올리다 갱신을 잃는 장면과 교착을 직접 만듭니다. 그 실습을 마친 사람도 서비스 코드에서는 세 가지를 자주 착각합니다. 파이썬에는 GIL 이 있으니 x += 1 은 안전하다. asyncio 는 스레드가 하나뿐이라 경쟁이 없다. 데이터베이스가 알아서 막아 준다. 셋 다 틀렸고, 이 모듈은 셋을 숫자로 확인합니다.
어떻게 동작하나
GIL 이 보장하는 것과 안 하는 것. 용어집은 GIL 을 CPython 이 한 번에 한 스레드만 바이트코드를 실행하게 하는 장치로 정의합니다. 라이브러리 FAQ는 파이썬이 스레드를 대개 바이트코드 명령 사이에서만 바꾸므로 명령 하나는 원자적이라고 설명하고, L.append(x) 는 원자적이지만 i = i+1 이나 D[x] = D[x] + 1 은 아니라고 적습니다. dis 로 counter += 1 을 풀면 이 실습 이미지(3.12)에서 LOAD_GLOBAL · LOAD_CONST · BINARY_OP · STORE_GLOBAL 네 명령이 나옵니다. 읽기와 쓰기가 다른 명령이니 그 사이에서 스레드가 바뀌면 남이 올린 값이 덮입니다. 얼마나 자주 바뀌는지는 sys.setswitchinterval 이 정하는데, 문서는 그 값이 '이상적인' 시간 조각일 뿐이고 어느 스레드가 이어받을지는 운영체제가 정한다고 적습니다. 그래서 경쟁은 시험에서는 잘 안 보이다가 부하가 오를 때 나타납니다. 실습은 읽기와 쓰기 사이에 time.sleep(0) 을 끼워 틈을 일부러 넓힙니다. 버그를 만드는 것이 아니라 원래 있던 버그를 매번 보이게 하는 장치입니다.
락은 틈 전체를 감싸야 합니다. threading 의 Lock 은 with 문에 들어갈 때 acquire, 나올 때 release 됩니다. 흔한 반쪽 수정은 쓰기만 락으로 감싸는 것입니다. 읽은 값이 이미 낡았으니 쓰기를 줄 세워도 낡은 값이 차례로 기록될 뿐입니다. 좌석 예약처럼 "비었나 확인 → 예약" 하는 확인하고-행동하기(check-then-act)도 같아서, 확인만 락으로 감싸면 두 요청이 동시에 '비었다' 는 답을 들고 나옵니다.
asyncio 에서는 await 가 틈입니다. 이벤트 루프는 한 스레드에서 돌고 작업은 await 에서만 제어를 넘깁니다. await 가 없는 구간은 사실상 원자적이고 await 가 낀 구간은 아닙니다. 잔액을 확인하고 → 기록하러 await 하고 → 차감하면, 그 await 동안 같은 계좌의 다른 출금이 같은 잔액을 확인합니다. asyncio 동기화 도구 문서는 asyncio.Lock 을 async with 로 쓰라고 권하고, 획득이 공정해서 먼저 기다린 코루틴이 먼저 들어간다고 적습니다. 같은 문서는 이 도구들이 스레드 안전하지 않으니 OS 스레드 동기화에는 threading 을 쓰라고 못박습니다. 반대로 코루틴 안에서 threading.Lock 을 잡은 채 await 하면, 다음 작업이 그 락을 기다리며 루프 스레드 자체를 세워 버립니다.
루프를 막는 호출. asyncio 개발 안내는 CPU 를 1초 쓰는 함수를 직접 부르면 모든 작업과 IO 가 1초씩 밀린다고 설명하고, 디버그 모드는 100ms 넘게 걸린 콜백을 로그로 남긴다고 적습니다. time.sleep 이나 동기 드라이버 호출도 똑같이 루프를 세웁니다. 실습은 10ms 마다 깨어나는 작업이 예정보다 얼마나 늦게 깨어났는지를 '루프 지연' 으로 잽니다. asyncio.to_thread 는 함수를 다른 스레드에서 돌려 루프를 비워 주지만, 문서가 말하듯 GIL 때문에 보통 IO 성 함수에만 효과가 있습니다.
데이터베이스도 문장 사이가 틈입니다. SQLite 의 트랜잭션 문서는 읽기 트랜잭션 중에 쓰기가 오면 가능할 때만 쓰기 트랜잭션으로 올리고, 다른 연결이 이미 고쳤거나 고치는 중이면 SQLITE_BUSY 로 실패한다고 적습니다(잠금 단계). 반대로 트랜잭션 없이 SELECT 로 읽고 계산한 값을 UPDATE 로 쓰면 아무 오류 없이 갱신을 잃습니다. 파이썬 sqlite3 모듈은 기본 설정에서 INSERT·UPDATE·DELETE·REPLACE 앞에서만 트랜잭션을 암묵적으로 열기 때문에 SELECT 는 보호받지 못합니다. UPDATE t SET n = n + 1 은 읽기와 쓰기를 한 문장에 넣어 틈을 없앱니다.
| 자리 | 틈 | 막는 법 |
|---|---|---|
| 스레드 | 바이트코드 명령 사이 | threading.Lock 으로 읽기~쓰기 전체 |
| asyncio | await | asyncio.Lock 으로 확인~차감 전체 |
| DB | 문장 사이 | 원자적 UPDATE, 또는 실패를 드러내는 트랜잭션 + 재시도 |
GIL 과 속도. threading 문서는 CPython 에서는 한 번에 한 스레드만 파이썬 코드를 실행하니 여러 코어를 쓰려면 multiprocessing 이나 ProcessPoolExecutor 를 쓰고, IO 작업 여럿을 동시에 돌리는 데는 스레드가 여전히 알맞다고 적습니다. 용어집은 IO 중에는 GIL 이 항상 풀린다고 말합니다. 3.13 부터 GIL 을 끈 free-threaded 빌드를 만들 수 있지만 기본 빌드가 아니고, 그 안내서도 내장 타입의 내부 락에 기대지 말고 threading.Lock 을 쓰라고 권합니다. GIL 이 있든 없든 읽고-고치고-쓰기의 틈은 코드가 막아야 한다는 뜻입니다.
현장에서 만나는 모습
Node 도 구조가 같습니다. '요청 하나가 아니라 전부가 느려졌다' 코스가 이벤트 루프 지연을 관측·경보까지 다루니, 이 모듈의 루프 지연 측정은 그 파이썬판입니다. 행 잠금과 SELECT FOR UPDATE, 데드락은 '동시성 — 두 사람이 같은 행을 건드릴 때' 코스가, 스레드를 늘려도 CPU 작업이 안 빨라지는 것을 프로파일러로 보는 일은 'CPU·메모리 누수 판별' 코스가 다룹니다. 이 모듈은 그 사이, 틈이 어디에 있는지를 재는 자리입니다.
다음 실습에서 할 것
counter += 1 을 바이트코드로 풀어 보고, 스레드로 갱신을 잃은 뒤 락으로 막습니다. 좌석 이중 예약과 await 사이의 초과 인출을 만들고 고친 다음, 블로킹 호출이 루프를 얼마나 세우는지 재고 to_thread 로 옮깁니다. SQLite 두 연결로 조용한 손실과 시끄러운 실패를 비교하고, 마지막으로 GIL 아래에서 스레드와 프로세스의 속도를 잽니다.