LMCache 동시성·비동기 기초 — Lock / Future / eventfd
[!tldr] 업무 관점 takeaway raw_block 성능 작업(L1/L2 락 합치기, [[raw_block-batched-remove-PR|A1 batched_remove]])은 전부 락 경합을 줄이는 일이라 이 3개 개념이 전제다: ①
threading.Lock은 변수를 잠그는 게 아니라 "한 번에 한 스레드만 통과"시키는 출입증(컨벤션으로_index보호) — 그래서 락을 N번 잡느냐 1번 잡느냐가 throughput을 가른다. ② Non-MP는 asyncio Future(진동벨), MP는 eventfd(도어벨)로 완료를 알린다. ③ 두 모드 모두 실제 일은 LMCache가 한다 — vLLM은 요청자.
출처:
raw/background/threading_lock.md,eventfd.md,async_mechanism.md(based-on-commit eaa2bfee, 2026-05-20).
1. self._lock이 잠그는 것 — "출입증" 모델
파이썬 threading.Lock은 스레드를 잠그지도, 변수를 잠그지도 않는다. 단지 with self._lock: 블록 진입을 직렬화한다 (한 번에 한 스레드만).
with self._lock:
# 이 블록 안에는 한 번에 한 스레드만. 블록 바깥 코드는 영향 없음.
...
"_index를 보호한다"는 코드 강제가 아니라 개발자 컨벤션: "_index를 만지는 모든 코드는 반드시 with self._lock: 안에서." 모두가 지키면 _index가 반쯤 수정된 상태로 읽히는 일이 없다.
화장실 열쇠 비유: 열쇠 1개(
_lock)가 화장실 문을 잠그는 게 아니라, "들어갈 땐 열쇠 받자"는 규칙을 모두 지켜서 결과적으로 한 번에 한 명. 규칙을 어기고 열쇠 없이 들어가면 락도 못 막는다.
Core는 스레드가 없다 — 외부 ThreadPool이 동시 호출
RawBlockCore에 전용 스레드는 없다. 여러 스레드가 같은 인스턴스를 동시 호출한다 (MP 모드):
RawBlockL2Adapter
├── store ThreadPool (2) ──┐
├── lookup ThreadPool (1) ──┼──> 같은 RawBlockCore 인스턴스 메서드를 동시 호출
└── load ThreadPool (4) ──┘ (+ checkpoint thread)
한 시점에 7~8개 스레드가 put_many/exists_many/load_many_into/_snapshot_state를 동시 실행 → 모두 _index를 만지면 race(예: dict.items() 순회 중 다른 스레드가 dict[key]=... → 크래시). 이걸 막는 게 self._lock. → 락을 자주 풀었다 잡으면 그 틈마다 context switch·캐시 ping-pong이 누적되는 게 L1/L2/A1이 노리는 지점.
두 종류 lock 구분
| 종류 | 코드 | 의미 | 보호 대상 |
|---|---|---|---|
| 파이썬 threading lock | with self._lock: | 프로세스 내 스레드 동기화 | _index, _lock_refcnt 자체 |
| L2 lock (refcount) | _lock_refcnt[key] += 1 | "이 슬롯 evict 하지 마"는 논리적 잠금 | 캐시 슬롯 데이터 |
L2 lock은 디스크/OS lock이 아니라 숫자 카운터일 뿐.
2. 완료 알림 — Future(Non-MP) vs eventfd(MP)
비동기가 필요한 이유: 디스크 쓰기 50ms 동안 vLLM은 다른 일을 해야 함 → "시작해줘, 끝나면 알려줘." 문제는 "어떻게 알리느냐", 그 답이 두 모드를 가른다.
| Non-MP (in-process) | MP | |
|---|---|---|
| 구조 | vLLM 프로세스 안, vLLM의 asyncio loop 위 | LMCache 별도 프로세스, 자체 ThreadPool |
| 완료 알림 | asyncio Future (진동벨) | eventfd (도어벨) |
| 한계 | 같은 프로세스에서만 작동 | 프로세스 달라도 fd 공유로 작동 |
- Future = 진동벨: 같은 가게(프로세스) 안에서만 울림.
- eventfd = 도어벨: 프로세스가 달라도 OS(커널) 차원에서 울림.
누가 일을 하는가 (핵심 오해 방지)
두 모드 모두 실제 일은 LMCache가 한다. vLLM은 요청자.
❌ "vLLM의 async loop에서 vLLM이 자기 순서에 캐시 일을 한다" ✅ "vLLM의 async loop에 LMCache가 자기 코루틴을 던져 넣고, LMCache의 코드가 그 loop 위에서 실행된다" (책상을 빌려주는 셈)
근거: rust_raw_block_backend.py가 loop를 주입받아(__init__(loop=...)) asyncio.run_coroutine_threadsafe(self._submit_put_one(...), loop)로 호출 — "다른 스레드에서 도는 asyncio loop에 코루틴을 제출". 이게 "asyncio loop 외부에서 받음"의 의미 (loop를 자기가 안 만들고 외부 주입).
3. eventfd는 ID가 아니다
eventfd = 리눅스 커널이 주는 "깨우기 전용 fd" (통신/동기화용 통로 자체). eventfd() 시스템콜로 만든다.
내부에 8바이트 카운터 하나:
write(efd, 1) → 카운터 += 1 (= "이벤트 발생")
read(efd) → 카운터 반환 + 0으로 리셋 (카운터 0이면 read는 블록/잠듦)
한쪽이 write하면 반대쪽이 read(또는 poll/epoll)에서 깨어남. 메시지 내용은 없다 — "뭔가 일어났다"만 알림 (내용은 별도 큐/공유메모리).
task_id (애플리케이션 ID) | eventfd (커널 신호) | |
|---|---|---|
| 무엇 | 작업 식별 숫자/문자열 | 신호 주고받는 통로 |
| 누가 만드나 | 앱 코드 (uuid4() 등) | 리눅스 커널 |
| 비유 | 영수증 번호 | 진동벨/도어벨 |
LMCache는 둘 다 사용: eventfd로 "store 결과 있다"고 vLLM을 깨우고, 깨어난 vLLM은 task_id로 결과 dict를 조회.
eventfd 3개 분리 (store / lookup / load)
MP adapter는 신호 통로를 3개로 나눈다 (store_efd/lookup_efd/load_efd). 한 통로면 깨어난 뒤 "어떤 일이 끝났지?"를 다시 확인해야 하므로, 종류별로 분리해 바로 해당 큐를 처리.
관련 페이지
- [[raw_block-성능-우선순위]] — L1/L2/B2+P2 등 락 관련 개선 항목
- [[raw_block-batched-remove-PR]] — 락 N→2 (삭제 경로) 실제 PR
- [[LMCache-MP-NonMP-모드]] — MP vs Non-MP 모드 차이 전반
- [[LMCache-Async-Loading]] — 비동기 로딩 경로 코드 위치
- [[raw_block-내부구조]] —
_lock/_index/_lock_refcnt코드 근거