본문으로 건너뛰기

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 lockwith 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.pyloop를 주입받아(__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 코드 근거