执行摘要
本 PR 将 rust/sglang-server 中散落在 runtime::start 与 api_server::serve 两处的 TCP 监听器绑定逻辑统一抽取为 utils::sock::bind_tcp_listener,集中管理 SO_REUSEADDR、SO_RCVBUF(16 MiB)、TCP_NODELAY、backlog(2048,对齐 uvicorn)等选项,并保持“端口占用即启动失败”的硬错误语义。变更净代码量为 +44/-44,不涉及 Python 侧推理逻辑,评审通过且已合并。风险点在于启动路径的绑定时机后移与 backlog 提升,且没有配套单元测试。
功能与动机
PR title 与 body 明确指出动机是 refactor the tcp socket/listener options and binding logic。原始实现中,socket 创建与参数调优逻辑内联在 runtime::start 的函数体里,而 api_server::serve 又通过 tap_io 在 accept 后重复设置 TCP_NODELAY,两处代码风格与错误处理不一致。重构目标是:把“选项在 listen 前设置”的细节集中到一个工具函数,统一行为,并让 EADDRINUSE 等绑定失败继续作为硬启动错误暴露。
实现拆解
- 新增
rust/sglang-server/src/utils/sock.rs:定义 bind_tcp_listener(addr) -> io::Result<TcpListener>,内部用 socket2 创建 socket,依次设置 SO_REUSEADDR、set_recv_buffer_size(16 MiB)、set_tcp_nodelay(true),再 bind、listen(2048)、set_nonblocking(true)。其中 SO_RCVBUF 与 TCP_NODELAY 设置失败仅 tracing::warn 不阻断启动,bind/listen 失败则直接向上传播。rust/sglang-server/src/utils.rs 同步注册 pub mod sock;。
runtime.rs 删除内联绑定逻辑并改用工具函数:runtime::start 开头约 30 行的 socket2 调用被移除,改为在 API 服务线程段内、spawn 线程前同步执行 bind_tcp_listener(http_addr),失败时 ? 返回错误,同时 drop shutdown_tx/senders 以停止启动进程。注释明确保留了“Binding synchronously so an unavailable port (EADDRINUSE) is a hard startup error”的设计意图。
api_server.rs 删除冗余后处理:serve 中原来的 listener.local_addr() 日志与 tap_io 设置 TCP_NODELAY 的代码被整体删除,因为 TCP_NODELAY 已在 listen 前设置且 accepted socket 会继承。现在 serve 直接执行 tokio::net::TcpListener::from_std 后进入 axum::serve。
测试与配置配套:无测试文件变更;无依赖或配置项变更。backlog 从原来硬编码的 1024 提升为常量 2048,与 Python 侧 uvicorn 默认值对齐,是唯一的行为微调。
rust/sglang-server/src/utils/sock.rs
新增文件,定义 bind_tcp_listener 核心函数,统一 socket 创建、选项调优与 listen 逻辑,是本次重构的主体。
//! Socket helpers for the API listener.
use std::io;
use std::net::SocketAddr;
use std::net::TcpListener;
// 对齐 uvicorn 默认值(asyncio 自己的默认是 100),避免高并发短连接下 accept 丢包。
const BACKLOG: i32 = 2048;
// 与 Python 侧保持一致的接收缓冲区大小。
const RECV_BUF_SIZE: usize = 16 * 1024 * 1024;
/// Bind and tune the API listener, returning it ready for
/// `tokio::net::TcpListener::from_std`.
///
/// socket2 rather than `TcpListener` so options (SO_RCVBUF, ...) can
/// be set before `listen`。
pub fn bind_tcp_listener(addr: SocketAddr) -> io::Result<TcpListener> {
let socket = socket2::Socket::new(
socket2::Domain::for_address(addr),
socket2::Type::STREAM,
Some(socket2::Protocol::TCP),
)?;
socket.set_reuse_address(true)?;
// 接收缓冲设置失败不阻断启动:只是性能变差,仍可继续服务。
if let Err(e) = socket.set_recv_buffer_size(RECV_BUF_SIZE) {
tracing::warn!(
"set_recv_buffer_size({RECV_BUF_SIZE}) failed: {e}; continuing with the default size"
);
}
// 对齐 Python:accepted sockets 继承 TCP_NODELAY,避免 Nagle/delayed-ACK 延迟。
if let Err(e) = socket.set_tcp_nodelay(true) {
tracing::warn!("set_tcp_nodelay failed: {e}; continuing without TCP_NODELAY");
}
socket.bind(&addr.into())?;
socket.listen(BACKLOG)?;
let listener: std::net::TcpListener = socket.into();
// 转非阻塞供 tokio `from_std` 接管。
listener.set_nonblocking(true)?;
Ok(listener)
}
rust/sglang-server/src/runtime.rs
启动路径主文件,删除内联绑定代码,改为调用 bind_tcp_listener 并后移绑定时机,保持 EADDRINUSE 硬失败语义。
// --- API server (tokio, I/O bound) ---
{
let cfg = cfg.clone();
let api_cores = plan.as_ref().map(|p| p.api.clone());
let senders = senders.clone();
let api_activity = egress_activity.clone();
let shutdown_rx = shutdown_rx.clone();
// Bind synchronously so an unavailable port (EADDRINUSE) is a hard
// startup error. The `?` drops `shutdown_tx`/`senders`, which stops the
// launcher process(drop 语义隐式通知各 worker 退出)。
let http_addr = cfg.rust_server_args.http_addr;
let listener = bind_tcp_listener(http_addr)
.map_err(|e| format!("binding API listener on {} failed: {e}", http_addr))?;
let handle = std::thread::Builder::new()
.name("api-runtime".into())
.spawn(move || {
// ... 构建 tokio multi-thread runtime,绑定 api 核,再 block_on(api_server::serve)
})
.expect("spawn api runtime");
threads.push(handle);
}
rust/sglang-server/src/api_server.rs
入口层删除冗余的 tap_io 设置 TCP_NODELAY 与 local_addr 日志,因为选项已在 bind_tcp_listener 中统一处理。
// The listener was already bound synchronously in `runtime::start` (so a port
// conflict fails startup); adopt it into the tokio reactor here.
let listener = match tokio::net::TcpListener::from_std(listener) {
Ok(l) => l,
Err(e) => {
tracing::error!(error = %e, "failed to adopt pre-bound listener");
return;
}
};
// `with_connect_info` exposes the peer address to the access-log middleware.
// TCP_NODELAY 已在 bind_tcp_listener 中设置(accepted sockets 继承),无需再 tap_io。
let serve = axum::serve(
listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
);
tokio::select! {
r = serve => {
if let Err(e) = r {
tracing::error!(error = %e, "axum serve exited");
}
}
_ = shutdown.recv_async() => {
tracing::info!("shutdown: stopping accepts, aborting in-flight handlers");
}
}
评论区精华
- mrain(reviewer):
nit: code could be cleaner to return io::Result<TcpListener> here and format the error message outside. Also set_recv_buffer_size failure may not be critical, you can still launch the server but with a worse performance. —— 核心建议是函数返回 io::Result,错误格式化交给调用方,并强调接收缓冲设置失败应降级而非失败。
- rainj-me(作者):回复 “Sure, will address this.” 后通过 “address comment” commit 落实,将函数签名改为
io::Result<TcpListener>,错误格式化上移到 runtime.rs。
- 最终 mrain APPROVED、sherlockwu “LGTM!”,无遗留争议。
风险与影响
- 绑定时机语义变化:绑定从
runtime::start 最前移到 API 服务段,仍为同步硬失败,但失败路径依赖 drop shutdown_tx/senders 来终止启动,属于隐式清理,未来若 Senders 持有额外资源需注意。
- backlog 提升:1024 → 2048,对齐 uvicorn;在极端高并发连接突发下可能增加内核 SYN 队列占用,一般无碍但未在 PR 中说明。
TCP_NODELAY 继承依赖:改为监听 socket 上设置并依赖 accept 继承,主流平台可行,跨平台需验证。
- 缺少测试:
bind_tcp_listener 无任何单元测试,端口占用、非法地址、选项失败降级等分支无回归保护。
- 影响面:仅限 Rust 前端启动路径,对推理、调度、Python 侧无影响;用户可见行为基本不变。
关联脉络
本 PR 属于 rust/sglang-server 模块持续成熟化的一部分。同仓库近期多个 PR 都在围绕 Rust 前端/扩展做配套治理:#33437 固定 Rust 工具链、#33384 让 Rust 扩展模块在 CI 中只构建一次、#33441 清理孤儿 site-packages 残留,与本 PR 的“代码整洁化”方向一致。后续演进大概率是继续把 Rust 前端的 socket 管理、鉴权(见 api_server.rs 中 TODO(auth))等 Python 侧能力逐步移植齐全,届时 bind_tcp_listener 可作为统一的监听器入口被复用。
参与讨论