LabHub
Get started
배우기 러닝패스 코스

CS for Building Good Services — Relearning Textbook Ideas by Measuring

Find, Count and Close the Gaps — Threads, asyncio, SQLite

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

목표

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": 금액}, …]}

단계

  1. /root/svccs/concurrency/bytecode.jsonpython(예: "3.12" 처럼 주·부 버전), ops, atomic 을 적습니다. opsdef bump(): global counter; counter += 1dis.get_instructions 로 푼 명령 이름 목록에서 RESUMERETURN 으로 시작하는 것을 뺀 것이고, atomic 은 이 증가가 스레드 사이에서 원자적인지(true/false)입니다.
  2. /root/svccs/concurrency/conc.pyunsafe_increment(threads, loops) 를 만듭니다. 스레드 threads 개가 공유 값을 loops 번씩 올리되, 한 번 올릴 때마다 읽기 → time.sleep(0) → 읽은 값 + 1 쓰기 순서로 합니다. {"expected": threads×loops, "actual": 최종값, "lost": expected − actual} 를 돌려줍니다. params.json 의 threads·loops 로 돌린 결과를 /root/svccs/concurrency/lost.jsonthreads·loops·expected·actual·lost 로 적습니다.
  3. 같은 파일에 locked_increment(threads, loops) 를 만듭니다. 2단계와 같은 읽기 → time.sleep(0) → 쓰기를 하되 threading.Lock 으로 막아 lost 가 0 이 되게 합니다. 양보(time.sleep(0))는 지우지 않습니다 — 채점기가 불린 횟수를 셉니다.
  4. 같은 파일에 book(requests, safe) 를 만듭니다. 요청마다 스레드 하나가 좌석이 비었나 확인 → time.sleep(0) → 예약 기록 을 합니다. safe 가 참이면 락으로 막습니다. {"requests", "seats"(서로 다른 좌석 수), "confirmed"(예약 성공 응답 수), "double_booked"(confirmed − 성공 응답에 나온 서로 다른 좌석 수)} 를 돌려줍니다. bookings.json 으로 두 번(락 없이·락으로) 돌려 /root/svccs/concurrency/seats.jsonrequests·seats·unsafe·safe 로 적습니다(unsafe·safe 는 각각 confirmed·double_booked 를 가진 객체).
  5. 같은 파일에 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": …} 로 적습니다.
  6. 같은 파일에 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.jsonhandlers·blocking·offloaded 로 적습니다(blocking·offloaded 는 measure_lag 가 돌려준 객체 그대로).
  7. 같은 파일에 sqlite_lost(path, rounds, mode) 를 만듭니다. 아래 'SQLite 규칙' 대로 두 연결을 한 스레드에서 번갈아 씁니다. params.json 의 sqlite_rounds 로 세 모드를 돌려 /root/svccs/concurrency/sqlite.jsonrounds·read_modify_write·atomic·deferred_txn 으로 적습니다.
  8. 같은 파일에 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

참고

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 을 내려놓으므로 스레드로 겹칩니다.