본문으로 건너뛰기

[Review] RawBlockCore put_many lock coalescing (4N → 2N)

[!tldr] 업무 관점 takeaway put_many가 키 하나당 core._lock4번 → 2번 잡도록 줄인 성능 커밋(ebc1506, workspace 작업분). 핵심은 _write_one이 자체적으로 들고 있던 _inflight_io_count 회계용 락(2회)을, 어차피 잡아야 하는 allocate/commit 락에 흡수시킨 것. 데이터 흐름(슬롯 할당→쓰기→확정)은 불변. 검증 결과 merge 차단 이슈 없음.if success: 한 줄 때문에 pwrite 실패 시 _last_io_ts 미갱신 이라는 미묘한 동작 변화가 생겨, 그 의도 확인 + commit message 문구 정정만 권장.

  • 대상 파일: lmcache/v1/storage_backend/raw_block/core.py, tests/v1/storage_backend/test_rust_raw_block_backend.py
  • 성격: 성능(락 경합 완화). 데이터 흐름 동작은 불변, idle gate 타이밍만 보수적으로 변함
  • 커밋: ebc1506[Perf][RawBlock] Reduce put_many lock count from 4N to 2N

1. 큰 그림 — 어디를 건드리나

RawBlockCore(core.py)는 LMCache L2 disk tier의 raw block 백엔드. 파일시스템을 거치지 않고 블록 디바이스에 KV 캐시를 고정 크기 슬롯 단위로 직접 쓴다. 슬롯 앞에는 헤더(키 identity, payload 길이)가 붙는다.

핵심 공유 상태 (core.py:240-258):

멤버의미
_index"이 키는 offset N에 확정 저장됨" — 완료 데이터 장부
_inflight"이 키는 슬롯 잡아놓고 쓰는 중" — 진행 중 임시 장부
_inflight_io_count지금 떠 있는 디바이스 I/O 건수 (정수 카운터)
_last_io_ts디바이스를 마지막으로 만진 시각 (time.monotonic())
_lock위 전부를 보호하는 단 하나의 threading.Lock (:240)

이 "단 하나의 락"을 누가, 몇 번 잡느냐가 이 리뷰의 전부다.


2. 배경 — 왜 락 횟수가 문제인가 (lock contention)

_lockthreading.Lock한 번에 한 스레드만 통과. 여러 스레드가 동시에 put_many를 부르면 이 문 앞에 줄을 선다.

비유: 창고 재고 장부가 딱 한 권이고, 보려면 열쇠 하나를 돌려 써야 한다. 장부를 자주 들출수록(=락을 자주 잡을수록) 다른 직원이 열쇠를 못 받아 일을 못 한다.

→ "장부 들추는 횟수(락 횟수)를 줄이자"가 이 커밋의 목적.


3. Before / After

Before — 키 하나당 락 4번

① allocate 락 : 슬롯 할당 + _inflight 등록
↓ (락 놓음)
_write_one() 안에서:
② 락 : _inflight_io_count += 1
↓ ...pwrite (디스크 쓰기)...
③ 락 : _inflight_io_count -= 1, _last_io_ts 갱신
↓ (락 놓음)
④ commit 락 : _inflight → _index 확정 (실패면 슬롯 반납)

_write_one이 디스크 쓰기를 하면서 카운터 회계까지 직접 하느라 ②③에서 락을 두 번 더 잡았다 → 키 N개면 4N.

After — 키 하나당 락 2번

핵심 아이디어: ②③(카운터 증감)을, 어차피 잡는 ①④ 락 안으로 옮긴다.

① allocate 락 : 슬롯 할당 + _inflight 등록 + _inflight_io_count += 1 ← 증가를 여기로
↓ (락 놓음)
_write_one() : 락 안 잡음. 순수 디스크 쓰기만.

④ commit 락(finally) : _inflight_io_count -= 1, (성공 시) _last_io_ts 갱신,
_inflight → _index 확정 (실패면 슬롯 반납)

→ 키당 2N. 데이터가 하는 일은 동일, 열쇠 돌리는 횟수만 절반.

증가는 allocate 락의 맨 끝 (core.py:467-516 부근):

self._inflight[key.encoded] = _Inflight(offset=offset, meta=meta)
self._inflight_io_count += 1 # ← 새 줄, 블록의 마지막

감소는 _write_one 직후 finally:

success = False
try:
success = self._write_one(key, obj, offset)
finally:
with self._lock:
self._inflight_io_count -= 1
if success:
self._last_io_ts = time.monotonic()
inflight = self._inflight.pop(key.encoded, None)
if inflight is not None and (inflight.canceled or not success):
... 슬롯 반납 ...
elif inflight is not None:
... _index 확정 ...

_write_one(core.py:898)은 이제 락/카운터 코드가 전부 빠지고 pwrite 두 번만 남는다.


4. 얽힌 4개념 — 여기만 이해하면 끝

이 변경이 까다로운 이유는 카운터가 checkpoint 기능과 얽혀 있기 때문이다.

개념한 줄 정의
_inflight_io_count지금 디스크 I/O가 몇 건 떠 있나. 0 = 한가함
_last_io_ts디바이스를 마지막으로 만진 시각
checkpoint_index 장부를 디스크에 영구 저장. 안 하면 죽었을 때 "키↔위치" 매핑 유실
idle gatecheckpoint를 떠도 되는지 판정하는 문 (core.py:1200-1206)

idle gate 실체 (_checkpoint_once, :1204):

idle_ok = self._inflight_io_count == 0 and (now - self._last_io_ts) >= quiet_ms

→ "진행 중 I/O가 0이고(=count) 마지막 I/O 후 충분히 조용할 때(=ts)만" checkpoint 허용. 즉 두 카운터는 단순 통계가 아니라 "지금 checkpoint 떠도 되나"를 결정하는 신호다. 그래서 카운터를 옮길 때 idle gate 동작이 안 바뀌는지가 제일 중요했다.


5. 검증 내용 — "안전하다"의 근거 3가지

검증 1) 카운터가 새지 않는다 (leak 없음)

무서운 시나리오: += 1 하고 -= 1을 못 하면 _inflight_io_count가 영원히 ≥1 → idle gate가 절대 안 열림 → checkpoint 영구 정지.

  • 증가(+= 1)를 allocate 락의 맨 끝에 둠 → 그 앞 모든 early-continue(이미 index / 이미 inflight / 슬롯 할당 실패)는 증가 전에 탈출 → 증가했으면 반드시 _write_one+finally 진입.
  • 증가 직후 success = False 한 줄(예외 불가)만 거치면 tryfinally-= 1 무조건 실행. ✅

검증 2) 윈도우가 넓어진 방향이 "안전한 쪽"

카운터가 1인 구간이 allocate→쓰기→commit 전체로 넓어짐(전엔 pwrite 주변만) → idle gate가 더 오래 닫혀 있음 → checkpoint가 더 늦게만 발생.

  • checkpoint가 너무 일찍 뜨는 게 위험(쓰는 중 데이터와 충돌). 늦게 뜨는 건 배칭 손해일 뿐 위험 아님 → 보수적 방향. ✅

검증 3) 동시 취소에도 안 깨짐

다른 스레드가 키를 중간에 취소/삭제 → popNone 반환.

  • -= 1pop 결과와 무관하게 먼저 실행되고, 이후 if inflight is not None 두 분기를 모두 건너뛰어 results[i]는 False로 유지. 변경 전과 동일. ✅

6. 코드리뷰 — 짚을 점

⚠️ 짚을 점 1 (확인 필요) — if success: 가 만든 동작 변화

변경 전 _write_one은 pwrite를 감싼 안쪽 finally에서 _last_io_ts무조건 찍었다. 변경 후엔 if success: 가드 때문에:

실패 종류변경 전변경 후일치?
prep 실패 (디스크 만지기 전, 예: O_DIRECT payload가 슬롯 초과)미갱신미갱신✅ 같음
pwrite 실패 (디스크 일부 만진 뒤 에러)갱신미갱신⚠️ 다름
  • 영향: 작다. 실패 슬롯은 반납+dirty 카운트되니 checkpoint는 결국 뜬다(단지 ts 기준으로 조금 일찍). data-loss 아님.
  • 다만 commit message의 "restores pre-L1 semantics" 는 prep 실패에만 정확하고 pwrite 실패엔 안 맞음 → 의도 확인 후 문구 정정 권장. pwrite 실패 timestamp 동작은 테스트 미커버.

👍 짚을 점 2 (잘한 점) — finally 안에서 continue 회피

변경 전 continue 흐름이 이번엔 finally 블록 안으로 들어갔다. finally 안의 continue/return은 그 순간 날아가던 예외를 조용히 삼킨다. 작성자가 continue 대신 if/elif로 재작성한 건 정확한 판단. (실무 단골 버그라 칭찬 포인트)

ℹ️ 짚을 점 3 — 다른 메서드는 여전히 옛 방식

load_many_into(core.py:584/634), _read_slot_header(:965/972)는 아직 자기 안에서 카운터 락을 따로 잡는다. 이번엔 _write_one만 손봄("put_many는 이미 ①④ 락을 잡으니 흡수 가능"). 합리적이지만 패턴이 코드 전체에서 통일된 건 아님.

ℹ️ 짚을 점 4 (nit) — 테스트의 _CountingLock 잉여 구현

put_manywith만 쓰므로 __enter__만 필요한데, 안 쓰는 acquire/release까지 구현. 무해하나 잉여.


7. 테스트 항목 — 5개가 각각 무엇을 잠그나

테스트무엇을 검사깨지면 의미
T1 ..._acquires_two_locks_per_keycore._lock_CountingLock으로 교체 → put_many(N)이 정확히 2N회 잡는지누가 _write_one에 락 재삽입 시 4N으로 빨개짐. 이 커밋의 간판 계약
T2 ..._releases_inflight_count_on_write_failurepwrite가 OSError 던지는 디바이스로 put → inflight_io_count()==0검증 1(누수)의 가드. 깨지면 카운터 박혀 checkpoint 정지
T3 ..._does_not_stamp_last_io_ts_on_prep_failure_prepare_write_payload 예외 패치 → _last_io_ts 불변짚을 점 1의 정상 케이스(prep 실패) 잠금. pwrite 실패는 미커버
T4 ..._inflight_io_count_visible_during_concurrent_putblock되는 디바이스로 별도 스레드 put → 그 순간 count>=1 확인 후 release카운터가 I/O 도중 실제로 1로 보인다는 관측 계약
T5 ..._checkpoint_idle_gate_blocks_during_put_many쓰기 진행 중 _checkpoint_once(force=False)가 False 반환검증 2의 핵심: inflight>0이면 gate 닫힘

T5가 정교한 이유 (교과서적 격리)

idle gate는 세 조건(dirty / timestamp / inflight)이 얽혀 있어, 그냥 테스트하면 무엇 때문에 막혔는지 모른다. 그래서:

  1. 먼저 1건 put → dirty 상태로 만듦 (안 그러면 not dirty에서 먼저 끝나 gate 평가 자체를 안 함)
  2. _last_io_ts를 과거로 밀어 timestamp 조건은 통과시킴
  3. 다음 put을 pwrite에서 막아 inflight==1 로 세팅

→ 두 조건을 무력화해 gate가 닫힌 이유가 오직 inflight 하나이게 만든 뒤 검사. "한 변수만 고립시켜 회귀를 잡는다"의 정석.


8. 결론

데이터 흐름(슬롯 할당→쓰기→확정)은 그대로 두고, _write_one이 따로 잡던 카운터 락 2회를 양옆 allocate/commit 락에 흡수해 키당 락을 4→2로 줄였다. 카운터 누수 없음·idle gate는 보수적 방향으로만 변함이 검증됨. 단 하나 if success: 때문에 pwrite 실패 시 timestamp 미갱신 이라는 미묘한 차이가 생겨, 그 의도 확인 + commit message 정정만 하면 merge 가능한 수준.

후속 제안

  • pwrite 실패 시 _last_io_ts 미갱신이 의도된 동작인지 확정
  • (의도라면) commit message의 "restores pre-L1 semantics" 문구를 prep 실패 한정으로 정정
  • (선택) pwrite 실패 케이스의 timestamp 동작 검증 테스트 1개 추가

관련 문서: [[raw_block-내부구조]] · [[raw_block-개선-Task]] · [[raw_block-Cleanup-PR]]