隙間を見つけて数え、塞ぐ — スレッド・asyncio・SQLite
한국어 원문으로 표시합니다.
목표
counter += 1 이 왜 원자적이지 않은지 바이트코드로 확인하고, 스레드의 잃어버린 갱신·좌석 이중 예약·asyncio 의 await 사이 초과 인출·SQLite 두 연결의 갱신 손실을 직접 만들어 세고 고칩니다. 이벤트 루프를 막는 호출의 비용을 '루프 지연' 으로 재고, GIL 아래에서 스레드와 프로세스가 CPU 작업을 얼마나 빨리 끝내는지 잽니다.
왜 중요한가
경쟁 조건은 읽은 값과 쓰는 값 사이의 틈에서 생깁니다. 스레드에서는 바이트코드 명령 사이, asyncio 에서는 await, 데이터베이스에서는 SELECT 와 UPDATE 사이가 그 틈입니다. 틈 일부만 락으로 감싼 수정은 오류 없이 돌면서 숫자만 틀리기 때문에 리뷰에서 잘 걸리지 않습니다. 그래서 이 실습의 채점기는 여러분이 적은 숫자만 보지 않고, 여러분의 함수를 재료가 아닌 입력에 다시 돌려 기준 구현과 대조합니다. 시간이 들어가는 판정은 파드가 CPU 를 나눠 쓰므로 넓은 범위로만 봅니다.
재료
/opt/fixtures/svccs/concurrency/ 아래에 있습니다. 읽기만 하세요.
params.json threads·loops(2·3단계) · sqlite_rounds(7단계) · lag_handlers·lag_block_s·lag_tick_s(6단계)
cpu_n·io_tasks·io_sleep_s(8단계)
bookings.json 좌석 예약 요청 목록 [{"user": "u001", "seat": "A01"}, …] — 같은 좌석 요청이 몰려 온다
accounts.json {"balances": {계좌: 잔액}, "withdrawals": [{"account": 계좌, "amount": 금액}, …]}
단계
/root/svccs/concurrency/bytecode.json에python(예: "3.12" 처럼 주·부 버전),ops,atomic을 적습니다.ops는def bump(): global counter; counter += 1을dis.get_instructions로 푼 명령 이름 목록에서RESUME과RETURN으로 시작하는 것을 뺀 것이고,atomic은 이 증가가 스레드 사이에서 원자적인지(true/false)입니다./root/svccs/concurrency/conc.py에unsafe_increment(threads, loops)를 만듭니다. 스레드threads개가 공유 값을loops번씩 올리되, 한 번 올릴 때마다 읽기 →time.sleep(0)→ 읽은 값 + 1 쓰기 순서로 합니다.{"expected": threads×loops, "actual": 최종값, "lost": expected − actual}를 돌려줍니다.params.json의 threads·loops 로 돌린 결과를/root/svccs/concurrency/lost.json에threads·loops·expected·actual·lost로 적습니다.- 같은 파일에
locked_increment(threads, loops)를 만듭니다. 2단계와 같은 읽기 →time.sleep(0)→ 쓰기를 하되threading.Lock으로 막아lost가 0 이 되게 합니다. 양보(time.sleep(0))는 지우지 않습니다 — 채점기가 불린 횟수를 셉니다. - 같은 파일에
book(requests, safe)를 만듭니다. 요청마다 스레드 하나가 좌석이 비었나 확인 →time.sleep(0)→ 예약 기록 을 합니다.safe가 참이면 락으로 막습니다.{"requests", "seats"(서로 다른 좌석 수), "confirmed"(예약 성공 응답 수), "double_booked"(confirmed − 성공 응답에 나온 서로 다른 좌석 수)}를 돌려줍니다.bookings.json으로 두 번(락 없이·락으로) 돌려/root/svccs/concurrency/seats.json에requests·seats·unsafe·safe로 적습니다(unsafe·safe는 각각confirmed·double_booked를 가진 객체). - 같은 파일에
run_withdrawals(balances, withdrawals, mode)를 만듭니다. 출금마다 코루틴 하나를asyncio.gather로 요청 목록 순서대로 띄우고, 각 코루틴은 잔액 ≥ 금액인지 확인 →await asyncio.sleep(0)→ 차감 합니다(모자라면 거절).mode가"locked"면 계좌별asyncio.Lock으로 막습니다.{"approved", "rejected", "overdrawn_accounts"(최종 잔액이 음수인 계좌 수), "final"(계좌별 최종 잔액)}을 돌려줍니다.accounts.json으로 두 모드를 돌려/root/svccs/concurrency/overdraft.json에{"unsafe": …, "locked": …}로 적습니다. - 같은 파일에
async def handler(offload, block_s=0.2)와measure_lag(offload, handlers=3, tick=0.01)를 만듭니다. handler 는offload가 거짓이면time.sleep(block_s)를 그대로, 참이면asyncio.to_thread로 옮겨 부릅니다. measure_lag 는 아래 '루프 지연 규칙' 대로 잽니다. 두 경우를/root/svccs/concurrency/lag.json에handlers·blocking·offloaded로 적습니다(blocking·offloaded는 measure_lag 가 돌려준 객체 그대로). - 같은 파일에
sqlite_lost(path, rounds, mode)를 만듭니다. 아래 'SQLite 규칙' 대로 두 연결을 한 스레드에서 번갈아 씁니다.params.json의 sqlite_rounds 로 세 모드를 돌려/root/svccs/concurrency/sqlite.json에rounds·read_modify_write·atomic·deferred_txn으로 적습니다. - 같은 파일에
cpu_bound(n)(0 부터 n−1 까지i*i % 7의 합),gil_compare(n),io_compare(tasks, sleep_s)를 만들고 아래 'GIL 규칙' 대로/root/svccs/concurrency/gil.json을 적습니다.
루프 지연 규칙
ticker 작업: 반복해서 due = loop.time() + tick → await asyncio.sleep(tick) → loop.time() − due 를 기록
ticker 를 띄우고 tick×3 만큼 기다린 뒤, handler(offload) 를 handlers 개 gather 한다(걸린 시간 = elapsed_s)
gather 가 끝나면 ticker 를 멈추고 기다린다
돌려줄 것: max_lag_ms(기록 최댓값 × 1000, 소수 첫째 자리) · ticks(기록 개수) · elapsed_s(소수 셋째 자리)
SQLite 규칙
path 에 새 DB: CREATE TABLE counter (id INTEGER PRIMARY KEY, n INTEGER NOT NULL), 행 (1, 0)
연결 a, b 는 sqlite3.connect(path, timeout=0.05). 라운드마다
read_modify_write a 가 n 을 읽고, b 가 n 을 읽고, a 가 (읽은 값+1) 을 쓰고 commit, b 도 같게
atomic a 가 UPDATE counter SET n = n + 1 후 commit, b 도 같게
deferred_txn a·b 의 isolation_level = None. a: BEGIN·읽기, b: BEGIN·읽기, a: 읽은 값+1 쓰기,
b: 읽은 값+1 쓰기 후 COMMIT — OperationalError 면 busy +1 하고 ROLLBACK, 끝으로 a: COMMIT
돌려줄 것: final(최종 n) · expected(2×rounds) · busy · lost(expected − final − busy)
GIL 규칙
gil_compare(n): cpu_bound(n) 을 한 번 돌려 데운 뒤, cpu_bound(n) 두 번을 순서대로 / 스레드 2개로 /
ProcessPoolExecutor(max_workers=2) 로 돌린다. 셋 다 벽시계(perf_counter)와 CPU 시간을 함께 잰다
→ cpu_seq_s · cpu_threads_s · cpu_procs_s (벽시계 초)
→ seq_cpu_s · threads_cpu_s (time.process_time 차이 — 이 프로세스 모든 스레드의 CPU 시간)
→ procs_cpu_s (os.times() 의 children_user + children_system 차이 — 끝난 자식 프로세스의 CPU 시간)
(여섯 값 모두 소수 셋째 자리)
io_compare(tasks, sleep_s): time.sleep(sleep_s) tasks 번을 순서대로 / 스레드 tasks 개로 → io_seq_s · io_threads_s
gil.json: gil_disabled_build(sysconfig.get_config_var("Py_GIL_DISABLED") 를 bool 로), 위 여덟 값,
thread_speedup = cpu_seq_s ÷ cpu_threads_s, proc_speedup = cpu_seq_s ÷ cpu_procs_s,
io_thread_speedup = io_seq_s ÷ io_threads_s,
thread_cores = threads_cpu_s ÷ cpu_threads_s, proc_cores = procs_cpu_s ÷ cpu_procs_s
(다섯 비율 모두 소수 둘째 자리 — cores 는 '그동안 평균 몇 개의 코어가 일했나' 입니다)
lost_updates_with_gil = lost.json 의 lost
참고
- 채점기는
conc.py를 불러옵니다. 결과 JSON 을 만드는 코드는if __name__ == "__main__":아래나 별도 스크립트에 두세요. - 스레드 경쟁의 손실 수는 운영체제가 순서를 정하므로 매번 다릅니다. 채점기는 고친 쪽이 정확히 0 인지, 안 고친 쪽이 0 보다 큰지를 봅니다. asyncio 와 SQLite 쪽 숫자는 매번 같습니다.
- 흔한 실수: 쓰기만 락으로 감싸기, 확인만 락으로 감싸고 행동은 밖에서 하기, 코루틴 안에서 threading.Lock 을 잡은 채 await 하기(루프가 멈춥니다), to_thread 로 옮겼다고 적고 time.sleep 은 그대로 두기.
- 산출물은 세션이 끝나면 사라집니다. 필요하면 따로 보관하세요.
counter += 1 은 명령 몇 개인가
counter += 1 을 dis 로 풀어 명령 이름 목록과 원자성 판단을 /root/svccs/concurrency/bytecode.json 에 python·ops·atomic 으로 적는다.
dis.get_instructions(함수) 는 명령을 하나씩 돌려주고 각 명령의 opname 이 이름입니다. 읽기(LOAD_)와 쓰기(STORE_)가 다른 명령이라면, GIL 이 명령 하나씩만 보장한다는 사실과 합쳐 무엇을 말해 주는지 생각해 보세요.
스레드로 갱신을 잃는다
conc.py 에 unsafe_increment(threads, loops) 를 만들고 params.json 의 threads·loops 로 돌려 /root/svccs/concurrency/lost.json 에 threads·loops·expected·actual·lost 를 적는다. 채점기는 다른 스레드 수·반복 수로도 함수를 부른다.
공유 값은 전역이나 dict 하나에 두고, 스레드마다 v = 값 → time.sleep(0) → 값 = v + 1 을 반복합니다. sleep(0) 은 GIL 을 내려놓아 다른 스레드가 같은 값을 읽을 틈을 만듭니다. expected 는 threads × loops 입니다.
락으로 틈 전체를 막는다
conc.py 에 locked_increment(threads, loops) 를 만든다. 읽기 → time.sleep(0) → 쓰기는 그대로 두고 threading.Lock 으로 lost 를 0 으로 만든다. 채점기는 여러 스레드 수로 부르고 time.sleep 이 불린 횟수도 센다.
락이 무엇을 감싸야 하는지가 이 단계의 전부입니다. 쓰기 한 줄만 감싸면, 락을 기다리는 동안 이미 읽어 둔 값이 낡아서 차례로 덮어씁니다. 읽기부터 쓰기까지를 한 덩어리로 보세요.
좌석 이중 예약 — 확인하고 행동하기
conc.py 에 book(requests, safe) 를 만들고, bookings.json 으로 락 없이·락으로 돌려 /root/svccs/concurrency/seats.json 에 requests·seats·unsafe·safe 를 적는다. 채점기는 다른 요청 목록으로도 부른다.
'비었나?' 와 '예약' 사이에 다른 스레드가 같은 좌석을 확인하면 둘 다 성공 응답을 받습니다. 확인만 락 안에 넣고 기록을 밖에 두면 틈은 그대로입니다. 성공 응답 목록에 좌석을 모아 두면 double_booked 를 셀 수 있습니다(list.append 는 FAQ 가 원자적이라고 적은 연산입니다).
await 사이의 초과 인출
conc.py 에 run_withdrawals(balances, withdrawals, mode) 를 만들고 accounts.json 으로 unsafe·locked 를 돌려 /root/svccs/concurrency/overdraft.json 에 적는다. 채점기는 다른 계좌·출금 목록으로 두 모드를 불러 기준 구현과 정확히 대조한다.
asyncio 는 스레드가 하나지만 await 에서 다른 코루틴에게 차례가 넘어갑니다. 확인과 차감 사이에 await 가 있으면 그 사이 같은 계좌의 다른 출금이 같은 잔액을 봅니다. 락은 asyncio.Lock 을 async with 로, 계좌마다 하나씩 두고 확인부터 차감까지를 감싸세요. threading.Lock 을 코루틴에서 잡은 채 await 하면 루프가 멈춥니다.
막는 호출 하나가 모두를 세운다
conc.py 에 handler(offload, block_s=0.2) 와 measure_lag(offload, handlers=3, tick=0.01) 를 만들고, 두 경우를 /root/svccs/concurrency/lag.json 에 handlers·blocking·offloaded 로 적는다. 채점기는 여러분의 handler 를 자기 측정기로 다시 잰다.
time.sleep 이 도는 동안 루프 스레드는 다른 어떤 작업도 깨우지 못합니다. handler 셋을 gather 하면 셋이 같은 루프 차례 안에서 이어서 막으므로 지연이 합쳐집니다. to_thread 로 옮긴 경우에도 일 자체는 해야 하니 elapsed_s 는 0 이 되지 않습니다.
SQLite 두 연결 — 조용한 손실과 시끄러운 실패
conc.py 에 sqlite_lost(path, rounds, mode) 를 SQLite 규칙대로 만들고, sqlite_rounds 로 세 모드를 돌려 /root/svccs/concurrency/sqlite.json 에 rounds·read_modify_write·atomic·deferred_txn 을 적는다. 채점기는 다른 라운드 수로 세 모드를 부른다.
파이썬 sqlite3 는 기본 설정에서 UPDATE 앞에서만 트랜잭션을 엽니다 — SELECT 로 읽은 값은 보호받지 않습니다. UPDATE ... SET n = n + 1 은 읽기와 쓰기가 한 문장입니다. 직접 BEGIN 한 두 연결이 모두 읽은 뒤 쓰려 하면, 한쪽은 쓰기로 올라가지 못하고 'database is locked' 를 받습니다.
GIL — 빨라지지 않는데 안전하지도 않다
conc.py 에 cpu_bound·gil_compare·io_compare 를 만들고 GIL 규칙대로 /root/svccs/concurrency/gil.json 을 적는다. lost_updates_with_gil 은 2단계 lost.json 의 lost 다. 채점기는 io_compare 를 직접 부르고 cpu_bound 의 값을 대조한다.
CPU 만 쓰는 함수는 GIL 을 거의 내려놓지 않아 스레드 둘이 차례로 돕니다. 프로세스는 인터프리터가 따로라 GIL 도 따로입니다. 벽시계 시간은 옆 파드가 붐비면 흔들리지만, 'CPU 시간 ÷ 벽시계 시간' 은 그동안 평균 몇 개의 코어가 일했는지라 GIL 의 흔적이 더 또렷합니다. time.sleep 은 기다리는 동안 GIL 을 내려놓으므로 스레드로 겹칩니다.