Rust双向TCP代理中TcpStream转发阻塞的正确实现方案
问题原因及底层原理
阻塞的根因和TCP全双工特性、std::io::copy的执行逻辑直接相关:
std::io::copy会持续从读端读取数据写入写端,直到读端返回EOF才会结束返回- TCP是全双工协议,两个方向的数据流独立管理:默认情况下只要没有主动关闭连接的写方向,对端的读操作就会一直阻塞等待数据,不会收到EOF
注释掉shutdown时的执行流程如下:
- 后端服务主动关闭了到代理的连接写方向,
dst2src线程的std::io::copy从destination读端拿到EOF,正常返回 - 但此时代理到客户端的
source连接的写方向没有被关闭,客户端不知道后端已经没有数据返回,可能还保持着连接的写方向等待后续响应 - 这时候
src2dst线程还在从source读端等待客户端发新数据,永远拿不到EOF,线程一直阻塞,后续src2dst.join()就会一直卡住
当前实现代理HTTP流量暂时没问题的原因是:HTTP1.x的请求响应模型是客户端发完请求后要么主动关闭写方向,要么服务端返回完响应后会主动要求关闭连接,两端的EOF都会正常触发,所以大概率不会卡,但不符合通用TCP场景要求。
通用TCP代理的正确实现
核心是要正确处理TCP半关闭逻辑:当任意一个方向的转发结束后,主动关闭对应连接的写方向,通知对端该方向已经没有更多数据发送,不要用Shutdown::Both直接关闭整个连接,否则可能丢失另一个方向还在传输的数据。
修正后的代码如下:
use std::net::{TcpStream, Shutdown}; use std::io; use std::thread; type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>; pub fn route(source: TcpStream, worker: Worker) -> Result<()> { let mut source = source; let mut destination = TcpStream::connect(worker.address)?; let mut source_read = source.try_clone()?; let mut dest_write = destination.try_clone()?; // 客户端->后端转发线程 let src2dst = thread::spawn(move || -> io::Result<u64> { let res = io::copy(&mut source_read, &mut dest_write); // 客户端已经没有数据发送,关闭到后端的写方向,通知后端EOF let _ = dest_write.shutdown(Shutdown::Write); res }); let mut dest_read = destination; let mut source_write = source; // 后端->客户端转发线程 let dst2src = thread::spawn(move || -> io::Result<u64> { let res = io::copy(&mut dest_read, &mut source_write); // 后端已经没有数据发送,关闭到客户端的写方向,通知客户端EOF let _ = source_write.shutdown(Shutdown::Write); res }); // 生产环境可按需处理错误、打印日志,不要直接用unwrap let _ = src2dst.join()??; let _ = dst2src.join()??; Ok(()) }
注意事项
- 上述实现可以覆盖所有通用TCP场景,包括需要半关闭的协议(比如FTP、SSH、WebSocket等)
- 如果代理的并发量较大,多线程模型的开销会比较高,可以考虑改用tokio等异步运行时实现,性能提升会非常明显
内容的提问来源于stack exchange,提问作者lewisxy
相关产品推荐
相关产品推荐

