L4 连接
主线位置:请求主线的第四步,连接池里没有可复用连接的那条岔路。
get_stream先问池子,池子说没有,就调new_stream新建:TCP 三次握手,需要的话再做 TLS 握手,得到一个Stream。域名解析不在这条路上,它更早,发生在用户的upstream_peer里构造HttpPeer的那一刻。
flowchart TB
peer["HttpPeer::new"] --> dns["to_socket_addrs:同步解析"]
dns --> getstream["get_stream:池里没有"]
getstream --> newstream["new_stream"]
newstream --> off{"offload?"}
off -- 是 --> rt["另一个运行时"]
off -- 否 --> doc["do_connect"]
rt --> doc
doc --> total["total_connection_timeout"]
total --> l4["l4::connect"]
l4 --> sock["建 socket、设选项、connect"]
sock --> tls{"peer.tls?"}
tls -- 是 --> hs["tls::connect:握手"]
tls -- 否 --> done["Box Stream"]
hs --> done
本页的源码在 connectors/mod.rs、connectors/l4.rs 和 protocols/l4/ext.rs 里建连接的那几个函数。Stream 本身(BufStream、回退缓冲、摘要)和 TCP 选项的实现在协议部分的 L4 流,TLS 握手的细节在 TLS。
实验平台:M2 Max、macOS 27、rustc 1.99.0,pingora 0.9.0,tokio 钉在 1.53.2(原因见怎么读这一卷),release 构建。
域名解析:在 HttpPeer::new 里同步完成
pingora 的连接器只认已经解析好的地址:Peer::address() 返回 &SocketAddr,l4::connect 拿它直接 connect()。域名在 HttpPeer::new 里解析:
/// Create a new [`HttpPeer`] with the given socket address and TLS settings.
pub fn new<A: ToInetSocketAddrs>(address: A, tls: bool, sni: String) -> Self {
let mut addrs_iter = address.to_socket_addrs().unwrap(); //TODO: handle error
let addr = addrs_iter.next().unwrap();
Self::new_from_sockaddr(SocketAddr::Inet(addr), tls, sni)
}
ToInetSocketAddrs 是 std::net::ToSocketAddrs 改了个名字。三件事:
- 同步。 参数是
"host:port"这样的字符串时,标准库调getaddrinfo,在当前线程上阻塞到解析结束。upstream_peer是async fn,但这一行没有.await,它就跑在代理服务的工作线程上。IP 字面量("127.0.0.1:18081")先按地址解析,成功就不碰getaddrinfo。 - 只取第一个地址。 解析出几个地址都只留
next()那一个,后面的直接丢掉;连不上也不会换下一个试。 - 失败就 panic。 两个
unwrap:解析出错,或者解析成功但一个地址都没有。
下面这个代理用一个自定义的 ToSocketAddrs 模拟慢解析:先 std::thread::sleep 1 秒,再解析 IP。它和真实的 getaddrinfo 一样是同步调用。/slow 走慢解析,/name 用 localhost:18481,/bad 用一个不存在的域名,其他路径用 IP 字面量。上游是 python3 -m http.server 18481 --bind 127.0.0.1。服务用默认配置,ServerConf 的默认值是 threads: 1:
struct SlowResolve(&'static str);
impl ToSocketAddrs for SlowResolve {
type Iter = std::vec::IntoIter<SocketAddr>;
fn to_socket_addrs(&self) -> std::io::Result<Self::Iter> {
std::thread::sleep(Duration::from_millis(1000));
self.0.to_socket_addrs()
}
}
#[async_trait]
impl ProxyHttp for P {
type CTX = ();
fn new_ctx(&self) {}
async fn upstream_peer(&self, s: &mut Session, _: &mut ()) -> Result<Box<HttpPeer>> {
let path = s.req_header().uri.path().to_string();
let t = Instant::now();
let thread = std::thread::current().name().unwrap_or("?").to_string();
let peer = match path.as_str() {
"/slow" => HttpPeer::new(SlowResolve("127.0.0.1:18481"), false, String::new()),
"/name" => HttpPeer::new("localhost:18481", false, String::new()),
"/bad" => HttpPeer::new("no-such-host.invalid:80", false, String::new()),
_ => HttpPeer::new("127.0.0.1:18481", false, String::new()),
};
eprintln!("upstream_peer {path} on thread {thread:?}: HttpPeer::new took {:?}, addr {}", t.elapsed(), peer._address);
Ok(Box::new(peer))
}
}
客户端用 curl 依次发:/fast 单独一次;/slow 发出后 100ms 再发 /fast;两个 /slow 同时发;然后 /name、/bad,最后再来一次 /fast。
/slow 发出 100ms 后再发的那个 /fast,curl 记下的 time_total 大约是多少?两个同时发的 /slow 呢?/fast 自己的 HttpPeer::new 只要一两微秒。先猜,答案在文末。
localhost 那一行在 macOS 上值得单独看。代理日志:
upstream_peer /name on thread "Pingora HTTP Proxy Service": HttpPeer::new took 2.378042ms, addr [::1]:18481
[2026-10-29T04:53:15Z ERROR pingora_proxy] Fail to proxy: Upstream ConnectRefused context: Fail to connect to addr: [::1]:18481, scheme: HTTP cause: context: Fail to connect to [::1]:18481 cause: Connection refused (os error 61), status: 502, tries: 1, retry: false, GET /name, Host: 127.0.0.1:18480
localhost 解析出 ::1 和 127.0.0.1,第一个是 ::1。上游只绑了 127.0.0.1,于是 502,而 127.0.0.1 那个地址从头到尾没被试过。hyper-util 的 HttpConnector 在这里做的是另一套:getaddrinfo 放进阻塞线程池,拿到全部地址后做 Happy Eyeballs,见 hyper 卷的 HttpConnector。
/bad 让工作线程 panic:
thread 'Pingora HTTP Proxy Service' (5361160) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/pingora-core-0.9.0/src/upstreams/peer.rs:719:56:
called `Result::unwrap()` on an `Err` value: Custom { kind: Uncategorized, error: "failed to lookup address information: nodename nor servname provided, or not known" }
panic 发生在处理这个下游连接的任务里,tokio 接住它,任务结束,下游 socket 被关掉,curl 看到的是 Empty reply from server,没有任何 HTTP 响应。紧接着的 /fast 正常返回 200:服务没有停,只丢了这一个连接。
问: 那 upstream_peer 里拿到的是域名,该怎么办?
答: 自己先异步解析成 SocketAddr,再交给 HttpPeer::new。参数已经是 SocketAddr 时,to_socket_addrs 只是把它包成一个单元素迭代器,不阻塞也不会失败。异步解析可以用 tokio::net::lookup_host,它把 getaddrinfo 丢进 tokio 的阻塞线程池(见 tokio 卷的阻塞线程池),工作线程在等待期间可以处理别的连接;解析结果要缓存、要轮换多个地址,也只能自己做。pingora 的负载均衡组件也是先解析、后使用:Backend 里存的是 SocketAddr,Static::try_from_iter 在构造时把每个名字解析出的所有地址都变成一个 Backend(同步,但只在启动时做一次),见 LoadBalancer。
new_stream 和 do_connect
TransportConnector::new_stream 做三件准备,然后调 do_connect:
- 有 offload 线程池(
ServerConf的upstream_connect_offload_threadpools和upstream_connect_offload_thread_per_pool都大于 0)时,按peer.reuse_hash()选一个池,把整个建连过程 spawn 过去,当前任务只等结果。字段注释给的理由是 TCP 和 TLS 建连吃 CPU,放在服务线程上会拖慢所有流量,超时又引来更多新连接,「snowballs the issue」。 bind_to_random:peer 自己的bind_to里有源地址就用它;否则按目标是 IPv4 还是 IPv6,从ServerConf的client_bind_to_ipv4/client_bind_to_ipv6列表里随机挑一个。preferred_http_version.get(peer):这个 peer 以前 h2 出过错、被记成「以后用 h1」的话,这次 TLS 握手的 ALPN 换成只发http/1.1。
do_connect 只管总超时,do_connect_inner 是两段:
async fn do_connect_inner<P: Peer + Send + Sync>(
// ...
) -> Result<Stream> {
let l4_connect_start = Instant::now();
let mut stream = l4_connect(peer, bind_to).await?;
stream.set_establishment_timing(l4_connect_start.elapsed(), offload_wait_duration);
if peer.tls() {
// Capture the local address before the handshake consumes the stream, so a TLS failure
// can still report the address we connected from.
// ...
let tls_stream = tls::connect(stream, peer, alpn_override, tls_ctx)
.await
.map_err(|e| attach_connect_local_addr(e, local_addr))?;
Ok(Box::new(tls_stream))
} else {
Ok(Box::new(stream))
}
}
开了 offload 时,do_connect 是在 offload 运行时的任务里才被调用的,total_connection_timeout 从那时开始计时;在线程池队列里排队的时间单独记成 offload_wait,写进 TimingDigest,不算进总超时。
l4::connect
l4::connect 按 peer 分三条路:
get_proxy()有值:先连next_hop这个 Unix 域套接字,再发 HTTPCONNECT(proxy_connect,Windows 上直接 panic)。PeerOptions::custom_l4有值:交给用户实现的Connecttrait。- 否则按地址类型:
Inet走 TCP,Unix走connect_uds。
TCP 那条路在 connect() 之前给 socket 设选项,是一个闭包:
let connect_future = tcp_connect(
addr,
bind_to.as_ref(),
|socket| {
// ...
if peer.tcp_fast_open() {
set_tcp_fastopen_connect(raw)?;
}
if let Some(recv_buf) = peer.tcp_recv_buf() {
debug!("Setting recv buf size");
set_recv_buf(raw, recv_buf)?;
}
if let Some(dscp) = peer.dscp() {
debug!("Setting dscp");
set_dscp(raw, dscp)?;
}
if let Some(tweak_hook) = peer
.get_peer_options()
.and_then(|o| o.upstream_tcp_sock_tweak_hook.clone())
{
tweak_hook(socket)?;
}
Ok(())
},
&mut connect_attempt,
);
set_tcp_fastopen_connect、set_recv_buf、set_dscp 在 ext.rs 里都有两份:#[cfg(target_os = "linux")] 的那份真的调 setsockopt,其余平台的那份是空函数返回 Ok(()),例如 set_tcp_fastopen_connect 的非 Linux 版本。所以在 macOS 上 tcp_fast_open = true 不报错,也不起作用。upstream_tcp_sock_tweak_hook 是留给用户的口子,拿到的是 tokio 的 TcpSocket,想设什么自己设。
真正的 connect() 在 inner_connect_with:
let socket = into_socket2(socket);
if let Err(error) = socket.connect(&(*addr).into()) {
attempt.record_local_addr(&socket);
if !connect_in_progress(&error) {
return Err(wrap_os_connect_error(
error,
format!("Fail to connect to {}", *addr),
attempt.local_addr(),
));
}
} else {
attempt.record_local_addr(&socket);
}
let stream = TcpStream::from_std(socket.into())
.or_err(SocketError, "failed to register connecting socket")?;
stream
.writable()
.await
.or_err(ConnectError, "failed to wait for connecting socket")?;
if let Some(error) = stream
.take_error()
.or_err(SocketError, "failed to get connecting socket error")?
{
return Err(wrap_os_connect_error(
error,
format!("Fail to connect to {}", *addr),
attempt.local_addr(),
));
}
这是非阻塞 connect 的标准写法:tokio 的 TcpSocket 创建出来就是非阻塞的,connect() 立即返回 EINPROGRESS,然后等 socket 可写,再用 SO_ERROR(take_error)看连接到底成没成。在这之前,函数先设 IP_BIND_ADDRESS_NO_PORT,bind_to 带端口范围时再设 IP_LOCAL_PORT_RANGE,两个都只在 Linux 上有实现(后者要 Linux 6.3)。
操作系统的错误在 wrap_os_connect_error 里映射成 pingora 的错误类型:ConnectionRefused → ConnectRefused,TimedOut → ConnectTimedout,AddrNotAvailable → BindError,ENETUNREACH / EHOSTUNREACH → ConnectNoRoute,其余 ConnectError。l4::connect 再把 SocketError 和 BindError 包成 InternalError:这两种是本机的问题,不是上游的。错误类型决定代理要不要换个上游重试,那部分在错误、重试与故障转移。
连上之后,l4::connect 还做三件事:有 tcp_keepalive 就设(同样只在 Linux 有实现);无条件设 TCP_NODELAY;建一个 SocketDigest,把对端地址和本地地址填进去,后面 connected_to_upstream 钩子和日志里看到的本地端口就来自这里。
TcpSocket::connect0.8.0 的 inner_connect_with 最后一行是 socket.connect(*addr).await。tokio 的 TcpSocket::connect 按值拿走 socket,连接失败时 socket 已经被消费,内核分配的本地端口再也问不到;超时更糟,future 被外层的 timeout 直接丢弃。排查「某个源端口总是连不上」这类问题时,日志里没有这个端口。
提交 0d6bf54(「Refactor connect() so we can preserve port we bound to on failures」)把这一步改成上面看到的样子:先转成 socket2::Socket,自己调非阻塞的 connect,调完立刻 record_local_addr 记进 ConnectAttempt。ConnectAttempt 由调用方持有,所以即使外层的 pingora_timeout::timeout 把 future 丢掉,l4::connect 也还能把本地地址塞进 ConnectTimedout 错误。接着的 b319fed(「Preserve bind port on TLS failures」)把同样的信息带到 TLS 握手失败的错误里,就是 do_connect_inner 里的 attach_connect_local_addr。两个提交都首次发布在 0.9.0,CHANGELOG 记为「Preserve bound ports in digests after TLS failures.」。下一节实验的报错里 cause: local address 127.0.0.1:62762 就是它。
两个建连超时
PeerOptions 里管建连的超时有两个,文档注释分别说:
connection_timeout:「How long connect() call should be wait before it returns a timeout error.」total_connection_timeout:「How long the overall connection establishment should take before a timeout error is returned.」
第一个的注释只说了 connect(),代码里它其实用了两次:l4::connect 用它包住 TCP 连接,TLS 连接器又用它包住握手。openssl 连接器最后:
let connect_future = handshake(ssl_conf, peer.sni(), stream, complete_hook);
match peer.connection_timeout() {
Some(t) => match pingora_timeout::timeout(t, connect_future).await {
Ok(res) => res,
Err(_) => Error::e_explain(
ConnectTimedout,
format!("connecting to server {}, timeout {:?}", peer, t),
),
},
None => connect_future.await,
}
rustls 和 s2n 两个后端的 connect 结尾是同样的写法。所以 connection_timeout = 1s 的意思是「TCP 最多 1 秒,TLS 握手再最多 1 秒」,最坏 2 秒;要给整段一个上限,用 total_connection_timeout,它在 do_connect 里包住 do_connect_inner 整体。两个都不设,就没有上限。
实验直接调 TransportConnector::new_stream,不经过代理。一个上游端口没人监听;另一个上游只 accept,把 socket 攥在手里,一个字节也不回,TCP 能连上,TLS 的 ServerHello 永远不来。客户端自己用 tokio::time::timeout 包一层 3 秒,免得真的永远等下去:
async fn try_connect(label: &str, c: &TransportConnector, peer: &HttpPeer) {
let t = Instant::now();
// our own 3s guard, so a hang shows up as "still waiting"
match tokio::time::timeout(Duration::from_secs(3), c.new_stream(peer)).await {
Ok(Ok(_)) => println!("{label}: connected after {:?}", t.elapsed()),
Ok(Err(e)) => println!("{label}: {:?} after {:?}\n {e}", e.etype(), t.elapsed()),
Err(_) => println!("{label}: still waiting after {:?}", t.elapsed()),
}
}
#[tokio::main]
async fn main() {
let c = TransportConnector::new(None);
// 1. nothing listens on 18489
let p = HttpPeer::new("127.0.0.1:18489", false, String::new());
try_connect("refused", &c, &p).await;
// 3. TCP accepted, TLS ServerHello never comes
let l = TcpListener::bind("127.0.0.1:18483").await.unwrap();
tokio::spawn(async move {
let mut keep = vec![];
loop {
let (s, _) = l.accept().await.unwrap();
keep.push(s); // hold it open, never answer
}
});
let mut p = HttpPeer::new("127.0.0.1:18483", true, "silent.example".to_string());
try_connect("tls silent, no timeouts", &c, &p).await;
p.options.connection_timeout = Some(Duration::from_millis(300));
try_connect("tls silent, connection_timeout 300ms", &c, &p).await;
p.options.connection_timeout = None;
p.options.total_connection_timeout = Some(Duration::from_millis(500));
try_connect("tls silent, total_connection_timeout 500ms", &c, &p).await;
}
一次运行的输出(连跑 3 次,结论相同,超时那两行的毫秒数在 305–315ms 和 507–513ms 之间):
refused: ConnectRefused after 698.834µs
ConnectRefused context: Fail to connect to addr: 127.0.0.1:18489, scheme: HTTP cause: context: Fail to connect to 127.0.0.1:18489 cause: Connection refused (os error 61)
tls silent, no timeouts: still waiting after 3.004976208s
tls silent, connection_timeout 300ms: ConnectTimedout after 314.547958ms
ConnectTimedout context: connecting to server addr: 127.0.0.1:18483, scheme: HTTPS, sni: silent.example, timeout 300ms cause: local address 127.0.0.1:62762
tls silent, total_connection_timeout 500ms: ConnectTimedout after 512.804459ms
ConnectTimedout context: connecting to server addr: 127.0.0.1:18483, scheme: HTTPS, sni: silent.example, total-connection timeout 500ms
- 端口没人听:内核立刻回 RST,
ConnectRefused,不到 1ms。外层的Fail to connect to addr: ..., scheme: HTTP是l4::connect加的上下文(peer 的Display),里层的Fail to connect to 127.0.0.1:18489是wrap_os_connect_error加的。 - 两个超时都不设:TCP 已经连上,TLS 握手没有上限,3 秒时还在等。
PeerOptions::new()的默认就是这样,HttpPeer::new出来的 peer 碰上这种上游会一直挂着。 - 只设
connection_timeout:报错文字是connecting to server ... timeout 300ms,这是 TLS 连接器里那段代码的格式,不是l4::connect的timeout ... connecting to server。TCP 那一段早就成功了,300ms 花在 TLS 上。cause: local address 127.0.0.1:62762是上面冷知识里attach_connect_local_addr补上的本地地址。 - 只设
total_connection_timeout:500ms 时do_connect的超时触发,格式是total-connection timeout 500ms。这一层在attach_connect_local_addr外面,所以没有本地地址。
动脑筋答案
curl 的输出(-w "$1 %{http_code} %{time_total}s\n")。前三组又跑了两次,/fast 是 0.891 秒,两个 /slow 是 2.013 和 2.023 秒,和这次差 15ms 以内:
--- /fast alone
/fast 200 0.006213s
--- /slow, then /fast 100ms later
/slow 200 1.013188s
/fast 200 0.885383s
--- two /slow together
/slow 200 2.020329s
/slow 200 2.020173s
--- /name
/name 502 0.004284s
--- /bad
* Empty reply from server
* Closing connection
--- /fast after /bad
/fast 200 0.001504s
同一次的代理日志:
upstream_peer /fast on thread "Pingora HTTP Proxy Service": HttpPeer::new took 1.375µs, addr 127.0.0.1:18481
upstream_peer /slow on thread "Pingora HTTP Proxy Service": HttpPeer::new took 1.010022166s, addr 127.0.0.1:18481
upstream_peer /fast on thread "Pingora HTTP Proxy Service": HttpPeer::new took 1.25µs, addr 127.0.0.1:18481
upstream_peer /slow on thread "Pingora HTTP Proxy Service": HttpPeer::new took 1.01003575s, addr 127.0.0.1:18481
upstream_peer /slow on thread "Pingora HTTP Proxy Service": HttpPeer::new took 1.004554125s, addr 127.0.0.1:18481
...
- 晚到 100ms 的
/fast用了 0.885 秒,几乎就是/slow剩下的那 0.9 秒。它自己的HttpPeer::new只花了 1.25µs,可唯一的工作线程正在/slow的sleep里,连它的请求头都没人读。 - 两个同时发的
/slow都用了 2.02 秒:两次慢解析在同一个线程上排队,各 1 秒。第一个/slow的响应其实在 1 秒时就可以写了,但写响应的也是这个线程,它在第二个sleep里。 - 所有日志行都是
thread "Pingora HTTP Proxy Service":upstream_peer就在服务的工作线程上跑。这里只有一个工作线程;线程数和 work stealing 怎么影响「一个任务卡住线程」,见 运行时。
小结
- 域名解析发生在
HttpPeer::new:std::net::ToSocketAddrs,同步,只取第一个地址,失败 panic。实测慢解析把同一工作线程上的其他请求一起卡住;localhost在 macOS 上先解析出::1;panic 只断掉当前下游连接。 new_stream:可选 offload 到独立线程池,按reuse_hash选池;选源地址;读「以后用 h1」的记录。l4::connect:CONNECT 代理、自定义 L4、TCP / UDS 三条路;TCP 先设选项再非阻塞connect,TCP_FASTOPEN、接收缓冲、DSCP、keepalive、IP_BIND_ADDRESS_NO_PORT只在 Linux 有实现;连上后总是设TCP_NODELAY。connection_timeout分别限制 TCP 连接和 TLS 握手,total_connection_timeout限制整段,默认都没有;实测 TLS 不回时三种配置分别是一直等、300ms、500ms。- 0.9.0 起建连失败的错误里带着本地端口(
0d6bf54、b319fed)。