ARTICLE · 1126880
Codex 源码-CodexThread 与 ThreadManager
Codex 源码解析系列
第 11 讲:CodexThread 与 ThreadManager
基于 OpenAI Codex 源码 · 2026-10-05
💡 本讲一句话:第 10 讲的 run_turn 只是"一个 turn 怎么跑",这一讲回答"线程本身怎么活下来":CodexThread 是 Session 的门面(facade),ThreadManager 是线程的户籍科——创建、去重、注册、隔离、移除全在它手里。读完你会明白 Codex 为什么敢让多个客户端、子代理、Guardian 审查器同时挂在同一个进程里而不打架。
一、先建立地图:thread = Session 的门面 + 户籍表
第 8-10 讲我们一直在 Session 内部打转:TurnContext、系统提示词、agent loop。但外部世界(TUI、app-server、子代理)从来不直接碰 Session——它们手里拿的是一个叫 CodexThread 的东西,由一个叫 ThreadManager 的组件统一发放和管理。
源码里这条链路横跨两个文件:core/src/codex_thread.rs(1012 行,门面本体)和 core/src/thread_manager.rs(2492 行,户籍科)。本讲按"结构定义 → 双向通道 → turn 提交三模式 → 挂起交接 → 共享状态 → spawn 主流程 → 注册握手 → 生命周期管理"的顺序拆完这两个文件的核心路径。
📌 一句话定位:CodexThread ≈ "会话的遥控器"(只转发、不干活);ThreadManager ≈ "户籍科 + 资源池"(决定谁能出生、谁可见、怎么注销)。真正的执行体 Session 藏在两者背后。
二、CodexThread:一个只有 7 个字段的门面
先看结构体本身。它小到让人意外——没有配置、没有历史、没有任何业务状态,全部委托给 Arc<Session>:
📄 codex-rs/core/src/codex_thread.rs (第 179-224 行)
pub struct CodexThread { // 线程门面:外部调用方(TUI/app-server/子代理)看到的只有它,看不到 Session pub(crate) session: Arc<Session>, // 真正的运行时在 Session;thread 只持 Arc 引用、不拥有其生命周期 pub(crate) io: SessionIo, // 双向通道端点:提交 op / 接收事件都走这里(下一节细拆) pub(crate) session_source: SessionSource, // 注册来源决定访问权与生命周期钩子(用户/内部/Guardian) session_configured: SessionConfiguredEvent, // 启动时首个事件的快照,供外部查询线程配置 rollout_path: Option<PathBuf>, // rollout 录制文件路径;ephemeral 或未落盘时为 None out_of_band_elicitations: Mutex<OutOfBandElicitations>, // 带外 elicitation 状态(计数+注册),用互斥锁保护 _diagnostics_guard: GaugeGuard, // drop guard:线程被丢弃时自动把 LIVE_THREADS 指标减一}impl CodexThread { // 门面方法组:所有对外入口都挂在这里 pub(crate) fn new( // 内部构造器:只有 ThreadManager 能创建线程(注册是它的职责) session: Arc<Session>, // 运行时本体,与 SessionIo 等持有者共享同一份 Arc io: SessionIo, // 通道端点;所有发送方 drop 掉即终止 session loop session_configured: SessionConfiguredEvent, // 首事件必须是它,否则 finalize_thread_spawn 会报错 rollout_path: Option<PathBuf>, // 录制路径由外部传入(来自 session 配置) session_source: SessionSource, // 来源决定它对客户端是否可见、能否被移除 ) -> Self { // 构造并返回门面实例 Self { // 逐字段初始化 session, // 原样透传运行时引用 io, // 原样透传通道端点 session_source, // 原样透注册来源 session_configured, // 原样透传配置快照 rollout_path, // 原样透传录制路径 out_of_band_elicitations: Mutex::new(OutOfBandElicitations::default()), // elicitation 状态从零开始 _diagnostics_guard: LIVE_THREADS.track(), // 登记进存活线程指标,drop 时自动释放 } // 结构体字面量结束 } // new() 结束} // impl 块结束为什么这样设计?三个细节值得注意。其一,门面不持有状态:配置、历史、工具注册全在 Session 里,CodexThread 只是"遥控器"——这意味着同一个 Session 理论上可以挂多个门面(虽然实际上一对一),而反过来 Session 无法脱离 ThreadManager 的注册独立存在。其二,_diagnostics_guard 用 Rust 的 drop 语义做指标回收:线程对象一被丢弃,存活计数自动减一,不需要任何显式"注销指标"调用——这是 Rust 资源管理的典型用法。其三,session_source 被存进门面而不是只存在 Session 里:因为访问控制判断(能不能 get、能不能 remove)发生在 ThreadManager 这一层,它需要不进入 session loop 就能快速回答"这个线程对客户端可见吗"。
三、SessionIo:把"通道端点"从运行时里拆出来
门面里的 io 字段是理解整个线程模型的关键。它的定义在 core/src/session/mod.rs,源码注释直接点破了设计意图:
📄 codex-rs/core/src/session/mod.rs (第 395-407 行) + codex_thread.rs (第 226-228 行)
pub(crate) struct SessionIo { // 会话双向流的通道端点,与运行时状态刻意分离 pub(crate) tx_sub: Sender<Submission>, // 发送端:所有 op(用户输入/工具结果/控制命令)都走这条 channel pub(crate) rx_event: Receiver<Event>, // 接收端:turn 进度/完成等事件从这里拉取 pub(crate) agent_status: watch::Receiver<AgentStatus>, // agent 最新状态,watch channel 可订阅 pub(crate) session_loop_termination: SessionLoopTermination, // 共享 future:多个调用方都能等待 loop 退出}pub async fn submit(&self, op: Op) -> CodexResult<String> { // 最基础入口:提交一个 op,返回 submission id self.io.submit(op).await // 直接转发给 SessionIo;thread 自身不持有任何业务逻辑}为什么这样设计?源码注释写得很直白:"Runtime state lives on Session; keeping these endpoints separate lets all submission senders be dropped to terminate the session loop"——终止一个线程 = drop 掉所有 tx_sub 发送方。这是 tokio mpsc channel 的经典用法:channel 关闭即 EOF,session loop 读到 None 就自然退出,不需要显式 kill 信号、不需要锁、不需要状态机标记"已停止"。session_loop_termination 是一个共享 future(Shared<BoxFuture>),让"等待线程真正死透"这件事可以被多个调用方同时 await——下一节的 suspend 协议就靠它收尾。
📌 关键洞察:Codex 的"线程终止"没有 kill 命令,只有引用计数归零。ThreadManager 从表里 remove、客户端 drop Arc、子代理结束——任何一条路径走完,最后一个 tx_sub 发送方消失,session loop 就安静地退出并 flush rollout。
四、turn 提交三模式:start / steer / if-idle
用户输入进来后,门面提供三个语义不同的入口。它们最终都汇入同一个分发函数 submit_turn_input_with_mode:
📄 codex-rs/core/src/codex_thread.rs (第 327-333、467-480 行)
pub async fn start_or_steer_turn( // 最通用入口:空闲就开新 turn,忙碌就把输入 steer 进当前 turn &self, // 门面是 Arc 共享的,方法不要求独占 request: TurnInputRequest, // 用户输入(文本 / 命名函数调用结果)) -> CodexResult<TurnInputSubmission> { // 结果是枚举:Started / Steered / NotSubmitted{reason} self.submit_turn_input_with_mode(request, TurnInputMode::StartOrSteer).await // 以 StartOrSteer 模式委托统一分发器}async fn submit_turn_input_with_mode( // 统一分发:容量检查 + 通道提交,三模式的公共底座 &self, // 同上:共享门面 request: TurnInputRequest, // 与上面相同的输入载荷 mode: TurnInputMode, // StartOrSteer / StartIfIdle / Steer{expected_turn_id}) -> CodexResult<TurnInputSubmission> { // 返回统一的结果枚举 if !matches!(mode, TurnInputMode::Steer { .. }) { // steer 不需要容量检查(它不启动新 turn) self.session.services.agent_control.ensure_execution_capacity_for_turn_start(self).await?; // 多智能体预算:先确认有执行配额才允许开新 turn } // if 结束 self.io.submit_turn_input(request, mode).await // "到底 start 还是 steer"由 session loop 单线程裁决,门面不猜} // submit_turn_input_with_mode 结束为什么这样设计?核心是把"状态判断"从调用方挪进 session loop。门面不检查"线程现在忙不忙"——它只负责把 (输入, 模式) 塞进 channel,由单线程的 session loop 在唯一权威的状态上裁决:StartOrSteer 模式下忙碌就 steer、空闲就 start;StartIfIdle 模式下忙碌就直接 NotSubmitted(连入队都不做);Steer 模式带 expected_turn_id,turn id 对不上就拒绝——这是乐观并发控制:调用方不需要先查状态再提交(那样有竞态),而是把"我期望的世界"写进请求里,由 loop 验证。容量检查只对"开新 turn"生效:steer 是往已有 turn 里加输入,不消耗新的执行配额。
| start_or_steer_turn | ||
| start_turn_if_idle | ||
| steer_turn |
五、suspend_turn_and_shutdown:一次"所有权交接"协议
最精妙的门面方法是 suspend_turn_and_shutdown:把"正在跑、还没完成"的 root turn 挂起并关闭 session,让另一个 worker 进程接管恢复(比如 app-server 重启、任务迁移)。它的 doc comment 长达十几行,核心承诺是:"The session processes an accepted request even if its caller disconnects"——调用方挂了,交接也不能断:
📄 codex-rs/core/src/codex_thread.rs (第 417-445 行)
pub async fn suspend_turn_and_shutdown(&self) -> CodexResult<SuspendTurnOutcome> { // 挂起未完成的 root turn 并关闭 session,供另一个 worker 接管恢复 if self.session_source.is_non_root_agent() { // 子代理没有这个权利:只有拥有它的 root thread 能发起挂起 return Err(CodexErr::UnsupportedOperation("turn suspension requires the owning root thread".to_string())); // fail loud:明确报错而不是静默降级 } // 子代理守卫结束 let (reply, result) = oneshot::channel(); // 一次性应答通道:整个交接过程的所有权交给 session self.io.tx_sub.send(Submission { // 把控制 op 送进主循环;即使本调用方断开,session 也会继续处理它 id: new_submission_id(), // submission id,用于追踪这次控制请求 op: Op::SuspendTurnAndShutdown { reply }, // op 里带着通道的一半(reply),session 完成后用它回话 trace: current_span_w3c_trace_context(), // 附带 W3C trace context,跨进程关联同一条链路 parent_turn_id: None, // 挂起是线程级动作,不绑定任何具体 turn root_turn_id: None, // 同上:由 session loop 自己定位要挂起的 root turn }).await.map_err(|_| CodexErr::Fatal("thread session has stopped".to_string()))?; // channel 已关 = loop 已死,直接报 fatal let outcome = result.await.map_err(|_| CodexErr::Fatal("thread suspension reply was lost".to_string()))??; // 等 session 回话(停执行 + flush 历史 + 关闭 writer) if matches!(&outcome, SuspendTurnOutcome::Suspended { .. }) { // 挂起成功才需要等 loop 真正退出 self.io.session_loop_termination.clone().await; // await 共享终止 future:确认资源全部释放后才返回调用方 } // if 结束 Ok(outcome) // 返回结果(Suspended / Refused{reason})给接管方决策} // suspend_turn_and_shutdown 结束为什么这样设计?这是一个教科书级的"把长事务的所有权移交给单线程循环"模式。难点在于:挂起 = 停止执行 + flush rollout 历史 + 关闭 writer,三步必须原子完成;而调用方(比如正在重启的 app-server)随时可能消失。解法是把 reply 通道塞进 op 里——session loop 成为交接的唯一所有者,调用方只是"等结果的人"而不是"执行者"。最后一步 session_loop_termination.clone().await 保证:返回 Suspended 时,loop 一定已经退出、rollout writer 一定已关闭——接管方拿到的历史文件是自洽的。注意它不记录 TurnAborted/TurnComplete(doc comment 明说),这样恢复方能沿用原来的 turn id 继续跑,对模型和客户端来说这个 turn "从未中断"。
六、ThreadManagerState:一张表 + 一池共享服务
切到户籍科。ThreadManager 本体只有两个字段(state + 测试 guard),真正的东西都在 ThreadManagerState 里——一个被 Arc 包起来的共享状态:
📄 codex-rs/core/src/thread_manager.rs (第 370-398 行)
pub(crate) struct ThreadManagerState { // 管理器的共享状态;用 Arc 包一层,让 AgentControl 能弱引用它而不必处处传 Arc<&Self> threads: Arc<RwLock<HashMap<ThreadId, Arc<CodexThread>>>, // 存活线程表:id → 门面;读写锁分离热读与冷写 thread_created_tx: broadcast::Sender<ThreadId>, // 广播通道:新线程诞生时通知订阅方(TUI 列表 / app-server) thread_id_generator: ThreadIdGenerator, // ID 生成器(支持 reserve_thread_id 预占号) auth_manager: Arc<AuthManager>, // 认证凭证全线程共享一份 models_manager: SharedModelsManager, // 模型目录与 fallback 策略也共享 git_root_discovery: Arc<GitRootDiscovery>, // Git root 发现结果在管理器级缓存,避免每个线程重复扫盘 environment_manager: Arc<EnvironmentManager>, // 环境(cwd / workspace roots)统一管理 starting_mcp_runtimes: std::sync::Mutex<Vec<std::sync::Weak<AtomicBool>>>, // 正在启动的 MCP runtime:用 Weak 引用,死线程不阻塞清理 skills_service: Arc<HostSkillsService>, // Skills 服务共享 plugins_manager: Arc<PluginsManager>, // 插件管理器共享 mcp_manager: Arc<McpManager>, // MCP 连接管理器(隔离会话会另建独立实例,见第七节) code_mode_session_provider: Arc<dyn CodeModeSessionProvider>, // Code Mode 会话提供者(trait object,宿主注入) extensions: Arc<ExtensionRegistry<Config>>, // 扩展注册表;隔离线程改用空注册表 user_instructions_provider: Arc<dyn UserInstructionsProvider>, // 全局指令提供者(宿主负责抓取与缓存) image_store: Arc<dyn AttachmentStore>, // 图片附件存储 thread_store: Arc<dyn ThreadStore>, // 持久化层:JSONL rollout + SQLite 元数据 agent_graph_store: Option<Arc<dyn AgentGraphStore>>, // 多智能体图存储(可选,依赖 state db) attestation_provider: Option<Arc<dyn AttestationProvider>>, // workload identity 证明(可选) external_time_provider: Option<Arc<dyn TimeProvider>>, // 外部时间源(可选,测试/特殊宿主用) session_source: SessionSource, // 本管理器实例的默认注册来源(cli / app-server / internal) installation_id: String, // 安装标识,遥测用 analytics_events_client: Option<AnalyticsEventsClient>, // 分析客户端(可被配置禁用) ops_log: Option<SharedCapturedOps>, // 测试模式:捕获提交的 op 供断言} // ThreadManagerState 结束为什么这样设计?这个结构体是理解 Codex 多租户模型的钥匙:"每线程独占"的只有 threads 表里那一行,其余全是进程级共享服务。auth、models、git discovery、skills、plugins——这些昂贵或全局一致的资源只建一次,所有线程 Arc 共享。注意 starting_mcp_runtimes 用 Weak<AtomicBool>:MCP runtime 启动是异步的,如果启动期间线程死了,强引用会让它永远卡在"starting"列表里;Weak 引用 + retain(|r| r.strong_count() != 0)(第七节会看到)让死线程自动从列表里蒸发。而 mcp_manager 虽然默认共享,但隔离会话会 new 一个独立实例——共享是默认值,隔离是显式选择。
七、spawn_thread 主流程:去重、隔离分支与 Session::spawn
spawn_thread(第 1937-2190 行)是户籍科最核心的函数,所有入口——start_thread、resume、fork、内部会话——最终都汇到这里。先看 resume 路径的去重逻辑:
📄 codex-rs/core/src/thread_manager.rs (第 1997-2027 行)
if let InitialHistory::Resumed(resumed) = &initial_history { // resume 路径:先查这个线程是否已经在表里活着 let mut threads = self.threads.write().await; // 拿写锁做"检查+去重",原子化防止两个 resume 竞态出两份运行时 if let Some(thread) = threads.get(&resumed.conversation_id).cloned() { // 表里命中:这个 id 已有运行时 if thread.is_running() { // 还在跑:直接返回它,而不是再造一个(resume 幂等) if matches!(thread.session_source, SessionSource::Internal(InternalSessionSource::Guardian)) { // Guardian 审查器是特例 return Err(CodexErr::InvalidRequest("cannot resume a live Guardian reviewer; use thread/read to inspect it".to_owned())); // 活着的 Guardian 归父线程池所有:拒绝交出,防止两个 owner 抢一个 writer } // Guardian 特判结束 if let Some(requested_rollout_path) = resumed.rollout_path.as_deref() && thread.rollout_path().as_deref() != Some(requested_rollout_path) { // 请求的 rollout 路径与在跑的不一致 return Err(CodexErr::InvalidRequest(format!("thread {} is already running with a different rollout path", resumed.conversation_id))); // 两边写不同文件,绝不能混用——直接报错 } // 路径校验结束 return Ok(NewThread { thread_id: resumed.conversation_id, session_configured: thread.session_configured(), thread }); // 原样返回现有运行时(幂等成功) } // "已在跑"分支结束 threads.remove(&resumed.conversation_id); // 没在跑(僵尸条目):先移除,让下面的流程重建 } // 表内命中分支结束} // resume 去重块结束为什么这样设计?这段代码回答了一个分布式系统经典问题:同一个 id 的 resume 请求并发到达怎么办?答案是"写锁内 check-and-act":检查和返回/移除都在同一把写锁里完成,不存在 TOCTOU 窗口。三个分支各有深意——活着就复用(幂等,客户端重试安全);Guardian 例外(它的 rollout writer 归父线程池所有,交出去会造成双写者);rollout 路径必须一致(两个客户端指向不同录制文件时,"返回现有运行时"会静默把 A 的输入写进 B 的文件——宁可报错)。僵尸条目则直接移除重建:表里有条目但 loop 已死,留着只会让后续 resume 误判。
去重之后是隔离分支(第 2037-2071 行):同一个 SessionIsolation 标志位,决定这个线程继承多少共享服务:
| 指令来源 | ||
| 扩展注册表 | ||
| MCP 管理器 | ||
| 多智能体版本 |
为什么这样设计?Guardian(第 26 讲的主角)是"用 Codex 审查 Codex"的安全组件——它必须看到被审线程的上下文,但绝不能继承宿主的能力面:不能读宿主的扩展、不能调宿主的 MCP、更不能递归 spawn 子代理形成审查器套娃。Isolated 分支用一个标志位把整条能力链切断,而不是在每个子系统里散落 if-else——这是"隔离作为一等公民"的设计。
八、finalize_thread_spawn:首事件握手 + 注册即唯一
Session::spawn 返回 (session, io) 后,最后一步是注册。这里有两个容易忽略的硬约束:
📄 codex-rs/core/src/thread_manager.rs (第 2198-2235 行)
let thread_id = session.thread_id(); // 线程 id 由 session 自己决定(或来自预占的 reserved_thread_id)let event = io.next_event().await?; // 等新 loop 的第一个事件——这是启动"握手",不通过就不注册let session_configured = match event { // 校验首事件的形状 Event { id, msg: EventMsg::SessionConfigured(session_configured) } if id == INITIAL_SUBMIT_ID => session_configured, // 必须是 SessionConfigured 且来自初始提交;否则视为协议违规 _ => return Err(CodexErr::SessionConfiguredNotFirstEvent), // 其他任何形状 = 启动失败,fail fast 不留半成品}; // 握手校验结束{ // 注册块(作用域锁:离开即释放写锁) let mut threads = self.threads.write().await; // 拿写锁做重复检查 if let std::collections::hash_map::Entry::Vacant(e) = threads.entry(thread_id) { // Vacant = 表里没有这个 id → 可以安全注册 let thread = Arc::new(CodexThread::new(session, io, session_configured.clone(), session_configured.rollout_path.clone(), session_source)); // 组装门面(结构定义见第二节) e.insert(thread.clone()); // 插入存活线程表;此后 get_thread 就能找到它 return Ok(NewThread { thread_id, thread, session_configured }); // 返回给调用方:id + 门面 + 配置快照 } // Vacant 分支结束} // 注册块结束if let Err(err) = io.shutdown_and_wait().await { warn!("failed to shut down duplicate thread {thread_id}: {err}"); } // 撞车:刚启动的这个是多余的——先把它关掉(尽力而为)Err(CodexErr::InvalidRequest(format!("thread {thread_id} is already running"))) // 再报错;表里保留的是旧运行时,新来的让路为什么这样设计?"首事件必须是 SessionConfigured"是一条启动协议:session loop 起来后第一件事是广播自己的配置快照(模型、cwd、rollout 路径……),调用方拿到它才知道这个线程长什么样。如果第一个事件不是它,说明 loop 内部状态已经错乱——此时注册进表只会制造一个"看起来能用"的坏线程,不如直接 fail fast。重复 id 的处理则体现了"旧者居先"原则:表里已有的运行时是权威(它的 rollout writer、子代理引用都挂在上面),新启动的那个被 shutdown_and_wait 干净关掉。注意顺序——先关新的、再报错,不留泄漏的 session loop。
九、生命周期管理:可见性过滤与三种移除入口
线程的"死"比"生"更讲究。ThreadManager 提供了三个语义不同的移除/查询入口,核心区别在于对内部线程(Guardian、子代理 worker)的态度:
📄 codex-rs/core/src/thread_manager.rs (第 1564-1570、1250-1264 行)
pub(crate) async fn get_thread(&self, thread_id: ThreadId) -> CodexResult<Arc<CodexThread>> { // 客户端侧查询:只放行非内部线程 let threads = self.threads.read().await; // 读锁足够(查询不改表) match threads.get(&thread_id) { // 按 id 查表 Some(thread) if !thread.session_source.is_internal() => Ok(thread.clone()), // 非内部 → 返回 Arc clone(引用计数 +1,出锁后安全使用) Some(_) | None => Err(CodexErr::ThreadNotFound(thread_id)), // 内部线程一律报"不存在":对客户端彻底隐藏 } // match 结束} // get_thread 结束pub async fn remove_thread_for_client(&self, thread_id: &ThreadId) -> CodexResult<Option<Arc<CodexThread>>> { // 客户端发起的移除 let mut threads = self.state.threads.write().await; // 检查与删除共用同一把写锁:被拒绝的 worker 原样留在表里 if threads.get(thread_id).is_some_and(|thread| thread.session_source.is_internal()) { // 目标是内部线程? return Err(CodexErr::InvalidRequest("live internal threads can only be removed by their owner".to_owned())); // 内部 worker 归其父所有,客户端无权动它 } // 内部线程守卫结束 Ok(threads.remove(thread_id)) // 从表移除:若无其他 Arc 引用,门面 drop → session loop 终止} // remove_thread_for_client 结束为什么这样设计?get_thread 对内部线程返回 ThreadNotFound 而不是 AccessDenied——这是刻意的信息隐藏:客户端连"存在一个 Guardian 审查器"这件事都不该知道(否则就成了攻击面枚举)。而 remove_thread_for_client 则明确拒绝并说明原因——移除是写操作,需要给调用方可操作的错误信息。第三个入口 remove_thread_if_matches(第 1270-1284 行)用 Arc::ptr_eq 比较指针:延迟清理任务执行时,同一个 thread id 下可能已经换了新运行时(比如 resume 重建),只删"我当初看到的那个",绝不误杀后来者——这是典型的 compare-and-delete 模式。
| get_thread | ||
| remove_thread_for_client | ||
| remove_thread_if_matches |
十、总结:一张表 + 两条数据流
| 定位 | ||
| 核心方法 | ||
| 并发原语 | ||
| 终止方式 |
线程创建路径(start / resume / fork 共用)
① 入口:start_thread / resume_thread_from_rollout
组装 StartThreadOptions + ThreadSpawnRequest;resume 先读 rollout 得到 InitialHistory。
▼
② 去重:写锁内 check-and-act
Resumed 且表里活着 → 直接返回(Guardian 拒绝、rollout 路径不一致报错);僵尸条目移除重建。
▼
③ 装配:隔离分支 + Session::spawn
🔹 Inherit:共享 extensions / mcp_manager,逐层合成指令🔹 Isolated:空注册表 + 独立 McpManager + 禁用多智能体
▼
④ 握手:首事件必须是 SessionConfigured
io.next_event() 校验形状与 INITIAL_SUBMIT_ID;不通过即 fail fast,不留半成品。
▼
⑤ 注册:Vacant entry → 入表 + ready 事件
🔹 id 撞车:shutdown_and_wait 关掉新来的,保留旧运行时🔹 成功:emit_thread_ready_lifecycle + broadcast thread_created
本讲三个带走点:
🔹 "门面零状态"是并发安全的来源:CodexThread 不持有任何可变业务状态,所有裁决都发生在 session loop 单线程里——调用方永远不需要猜"现在忙不忙",把期望写进请求(expected_turn_id / mode),由权威状态验证。🔹 "终止 = 引用计数归零":没有 kill 命令、没有停止标志位;drop 最后一个 tx_sub 发送方,channel EOF,loop 自然退出并 flush。suspend 协议则展示了长事务如何把所有权移交给 loop——调用方可死,交接不能断。🔹 "隔离是一等公民":Guardian 审查器通过 SessionIsolation::Isolated 一个标志位切断指令/扩展/MCP/多智能体整条能力链;内部线程对客户端报 ThreadNotFound 隐藏存在性——安全边界画在架构层,而不是散落的 if-else。
下一讲我们钻进 session loop 与模型之间的那根管子:LLM Client 与 Responses API 流式通信——SSE 事件怎么解析、重试怎么编排、超时怎么兜底。
📚 系列导航
← 第 10 讲:Agent Loop / Turn 执行循环
→ 第 12 讲:LLM Client 与 Responses API 流式通信
关注公众号「AI技术推荐官」获取更多源码解析内容