모델에게는 마음대로 병렬하라 하고, 아래는 잠금으로 막아요
모델이 보는 것, 실제로 도는 것, 이력에 적히는 것은 순서 셋이에요. 깃발 하나가 한 응답에 호출을 여러 개 보내게 하고, 읽기-쓰기 잠금 하나가 누가 겹쳐 들어갈지를 정합니다.
- build_prompt가 병렬 깃발을 true로 씀turn.rs L1321
- 요청을 짤 때 Lite 표시와 ANDclient.rs L952
- 라우터가 레지스트리를 찾고, 없으면 falserouter.rs L137
- Hidden은 스스로 병렬이라 해도 직렬registry.rs L472
- 먼저 spawn, 준비될 때까지 기다린 뒤 잠금을 잡음parallel.rs L144
- 병렬이면 read, 아니면 writeparallel.rs L152
- 결과는 FuturesOrdered 삽입 순으로 기장turn.rs L2135
모델에게 src/main.rs와 src/lib.rs를 같이 읽고, 패치 한 곳을 치라 해요. 화면에서는 읽기 둘이 거의 같이 진행되고, 패치 치는 그 박자만 한숨 멈춥니다.
런타임이 “병렬 가능”을 이 호출들이 겹쳐 달린다는 뜻으로 받으면, apply_patch 둘이 공유 diff 장부를 같이 고쳐 장부가 엉망이 돼요. 호출을 머리-꼬리로 이으면 파일 읽기 둘도 줄을 서서 벽시계 한 라운드를 더 씁니다. 요청 쪽 깃발은 모델이 상자 여러 개를 한 번에 보낼 의지가 있는지만 답하고, 상자가 바닥에 떨어질 때 누가 겹칠지는 답하지 못해요.
모델에 보내는 깃발은 Prompt에 있어요. 필드는 기본 false이고, 샘플 주 경로는 build_prompt를 타 깃발을 true로 씁니다. Responses 본문을 짤 때 모델이 Responses Lite인지와 한 번 AND해요. Lite에서는 이 깃발이 꺼집니다. 압축 묶음 경로 둘도 true로 박아, 주 샘플 요청 모양과 맞추고, websocket 재사용이 필드마다 비교할 때 이 깃발도 들어갑니다.
출처:codex-rs/core/src/session/turn.rs 1312–1328행 · codex-rs/core/src/client.rs 946–953행
실행 쪽에는 표가 하나 더 있어요. ToolExecutor의 supports_parallel_tool_calls는 기본 false입니다. 덮어쓰기를 빼먹으면 쓰기 잠금을 타요. exec_command, view_image, tool_search는 true로 덮고, apply_patch는 안 덮어요. 라우터가 먼저 레지스트리를 찾고, 없으면 false입니다. 노출이 Hidden이면 handler가 true를 돌려도 소용없어요. MCP는 서버 스위치나 읽기 전용 힌트가 더 필요하고, 기본은 여전히 직렬입니다.
출처:codex-rs/tools/src/tool_executor.rs 122–124행 · codex-rs/core/src/tools/registry.rs 470–473행
모델은 parallel_tool_calls가 true이거나 Lite에서 false인 것만 봐요. 누가 진짜 병렬인지는 못 봅니다. 어떤 도구가 실제로는 쓰기 잠금을 잡아도 도구 목록에서 항목이 빠지지는 않아요.
모델에 대한 허락은 호스트 스케줄과 나눌 수 있고, 다른 언어로 다시 써도 씁니다. 요청 쪽은 한 번에 호출을 여러 개 보낼 의지만 답하고, 실행 쪽은 이번이 남과 겹칠 수 있는지만 답해요. Lite 테스트가 깃발을 끈 건, 어떤 모델 경로에서는 이 힌트를 잃는 걸 저자가 받아들인다는 뜻입니다. 게이트는 항상 있어요. 모델이 한 응답에 호출 둘을 보내도 작업 둘은 그대로 spawn하고, 로컬 표대로 잠금을 잡습니다.
분류가 맞아도 문을 보는 사람은 필요해요. 읽기 둘은 같이 있을 수 있고, 쓰기 하나는 독점해야 합니다. 잠금을 먼저 잡고 MCP 서버 연결을 기다리면, 콜드 스타트 한 번이 옆에서 이미 준비된 exec_command까지 막아요.
공정성도 있어요. 뒤따라 온 읽기가 쓰기 머리 위로 넘어가면, 패치가 잠금을 영영 못 잡을 수 있어요. 표준 라이브러리 RwLock으로 바꾸면 우선순위가 OS에 매여, 쓰기 쪽이 굶을 수 있습니다.
샘플 하나가 tokio::sync::RwLock<()> 하나를 공유해요. 잠금이 지키는 값은 유닛 타입이라 업무 데이터는 없고 게이트만 합니다. 작업은 먼저 spawn하고, 선택적으로 준비를 기다린 뒤 잠금을 잡아요. 병렬이면 read, 아니면 write. 준비 대기는 잠금 밖에 있습니다. 아직 안 붙은 MCP 서버가 쓰기 잠금을 차지해 옆 shell까지 막지는 않아요.
출처:codex-rs/core/src/tools/parallel.rs 144–156행
이 잠금의 우선순위는 fair, 곧 write-preferring이에요. 줄을 선 쓰기 요청이 잡고 놓을 때까지 뒤 읽기 잠금은 나가지 않습니다. view_image가 아직 도는데 apply_patch가 이미 문 밖에 있으면, 새로 온 exec_command는 view_image와 겹칠 수 있어도 패치 뒤에 서야 해요. fair가 사는 것은 쓰기 쪽이 굶지 않는 것이고, 값은 뒤따라 온 읽기가 쓰기에 가로막히는 것입니다.
분류 알갱이는 도구 인스턴스라 이번 인자는 못 봐요. exec_command는 ls든 rm이든 읽기 잠금을 타고, apply_patch는 패치가 커도 쓰기 잠금을 탑니다. 관계없는 직렬 도구 둘도 서로 막아요. 업무 코드를 찾아도 용량 상한은 없습니다. 셸 열이 같이 들어갈 수 있고, 용량은 프로세스, 샌드박스, OS에 맡깁니다.
묻는 것은 지금 이 순간에 독점자가 있느냐예요. 식당은 여럿이 메뉴를 같이 볼 수 있고, 주방에 들어가는 건 한 명만입니다. 공정 전략은 잠금 구현에 적혀 있어요. 업무 코드는 병렬인지 독점인지만 묻습니다. 새 읽기가 새치기하는 잠금으로 바꾸면 쓰기가 굶을 수 있어요. 준비 대기를 잠금 밖에 두는 것도 같은 판단입니다. 아직 준비 안 된 사람은 문간을 차지하지 말 것.
exec_command 둘이 겹쳐 달리면, 나중에 보낸 것이 먼저 끝날 수 있어요. 끝난 순서로 이력을 쓰면 다음 라운드에 모델이 보는 결과 순서가 보낸 호출과 안 맞습니다. 스트림에서 OutputItemDone이 올 때마다 future를 만드는 쪽은 언제 착공할지를 맡아요. 여기는 착공 뒤에 누가 겹칠지, 결과를 어떤 순서로 기장할지를 맡습니다.
샘플 루프는 tool_future를 도착 순으로 FuturesOrdered에 밀어 넣어요. drain은 삽입 순으로 빼고 세션에 씁니다. 읽기 잠금이 셸 둘을 겹쳐 달리게 해도, 기장은 보낸 순서를 따릅니다. 먼저 보낸 것이 먼저 기장되고, 실제로 더 늦게 끝나도 그래요.
출처:codex-rs/core/src/session/turn.rs 2130–2140행
관측 순서와 실행 겹침은 구조에서 갈라져요. 병렬은 벽시계만 바꾸고 장부는 안 바꿉니다. Agent를 직접 만들 때는 이 큐 둘을 적어도 따로 쓰세요. 모델에게는 병렬이라 하고, 도구를 돌릴 때는 로컬 표를 보고, 결과는 호출 목록의 원래 순서로 받습니다.
DeepSeek Harness: 인자로 분류하고, 독점이 장벽
DSH는 도구마다 isConcurrencySafe(args)를 내놓게 해요. 정확한 true만 병렬에 들어갑니다. 선언이 없거나, 인자가 잘못됐거나, 분류기가 던지면 모두 독점이에요. bash에는 분류기가 없어 통째로 독점입니다. 스케줄러는 메시지 전체가 오기를 기다려, 연속 병렬 호출을 한 묶음으로 만들고, 독점 호출마다 묶음을 따로 두어 장벽으로 씁니다. 묶음 안은 기본 상한 10인 롤링 풀이에요.
Codex는 이 묶음이 없어도 돼요. 판단을 도구 단위 불리언으로 눌러, 잠금 하나로 장벽을 흉내 내니까요. DSH는 읽기 전용 bash도 직렬로 두어 동기를 일부 포기하고, Codex는 ls 둘도, rm 둘도 겹쳐 달리게 할 수 있어요.
Claude Code: 인자로 나누고, 읽기 전용 bash만 병렬
Claude의 isConcurrencySafe는 기본 false예요. BashTool은 병렬을 isReadOnly에 맡기고, 명령이 읽기 전용 제약을 통과해야 true를 돌려줍니다. ls는 병렬 배치에 들어갈 수 있고, 쓰기 부작용이 있는 명령은 직렬 배치로 가요. 연속 안전 호출은 한 배치로 묶어 동기를 타고, 불안전한 것은 각각 배치가 되며 배치 안에서도 하나씩입니다. 상한은 환경 변수에서 오고, 파싱이 실패하면 10이에요.
Codex의 exec_command는 명령 파싱을 건너뛰어, 쓰기 명령도 읽기 잠금에 들어가요. 세 쪽 모두 스케줄 메타데이터를 호스트에 숨기고, 문을 닫는 자리만 다릅니다. Codex는 기본값과 Hidden에서 닫아 셸을 가장 풀어 주고, DSH는 분류기에서 닫아 bash를 전부 직렬로 두며, Claude는 가운데에 있어요.
늦게 온 읽기가 새치기할 수 있나
view_image가 아직 도는데 apply_patch는 이미 문 밖에 줄을 섰어요. 그때 모델이 exec_command를 하나 더 보냅니다. 데모에서 늦게 온 읽기로 바꾸고 단스텝으로 끝까지 간 뒤, 아래 세 질문에 맞춰 보세요.
exec_command가 아직 도는 view_image와 겹쳐 달릴 수 있나요.apply_patch 칸을 읽기 패로 바꾸면, 게이트가 그걸 따로 막을까요.