WSL 2 Relay 进程解析:Linux 用户进程如何被创建,以及 Windows 与 Linux 之间的 IO 如何双向中转
【免费下载链接】WSLWindows Subsystem for Linux项目地址: https://gitcode.com/GitHub_Trending/ws/WSL
本篇技术指南聚焦 WSL 2 架构中的 relay 进程——一个由 session leader fork 出来的 Linux 进程。它负责代表用户创建真正的 Linux 进程(bash、curl 等),并通过与 wslservice.exe 建立的多个 hvsocket 通道,把 stdin/stdout/stderr、终端尺寸变化、进程退出状态在 Windows 侧与 Linux 侧之间双向中转。读完本文,你将理解 WSL 2 中「wsl.exe → wslservice.exe → session leader → relay → 用户进程」这条完整的进程创建与 IO 链路,并能对照 init.cpp 中的InitCreateProcessUtilityVm实现逐行验证每一环的工作原理。
一、Relay 在 WSL 2 组件图中的位置
Relay 不是一个独立的可执行文件,而是 WSL 2 虚拟机的 Linux 分布内部由 init fork 出来的一个「角色进程」。根据官方技术文档 relay.md 的定义:
Relay is a WSL2 Linux process created by a session leader. Its job is to create a Linux process on behalf of the user, and relay its output back to Windows.
在整体组件图中,它与 Windows 侧各组件的连接关系如下(引自 WSL Overview 的 mermaid 架构图):
这里必须先厘清一个常见误区:Linux 侧的 relay 进程与 Windows 侧的 wslrelay.exe 是两个不同的东西。wslrelay.exe是 Windows 可执行文件,职责是在 NAT 模式下中转 localhost 网络流量、以及显示wsl2.debugConsole开启时的调试控制台输出(见 src/windows/wslrelay/main.cpp);而本文讨论的 relay 运行在 Linux 用户态,专管「进程创建 + 标准 IO/终端/退出状态中转」。
从源码结构看,relay 的完整生命周期由三段代码串起来:
- src/linux/init/init.cpp 中的
SessionLeaderEntryUtilityVm(约 L3125)——session leader 的消息循环,等待创建进程请求; - 同文件中的
InitCreateProcessUtilityVm(约 L1340)——WSL 2 下创建用户进程的核心入口,relay 即在此 fork 出来; - src/shared/inc/lxinitshared.h 中定义的消息结构,规定了 Windows 与 Linux 之间传什么数据。
二、触发创建:LxInitMessageCreateProcessUtilityVm 消息
当你在 Windows 终端里敲下一条wsl命令,wslservice.exe 会通过 hvsocket 向 Linux 侧发送一条LxInitMessageCreateProcessUtilityVm消息。这个消息的类型定义在 lxinitshared.h 的消息枚举中(L317),其消息体结构为:
typedef struct _LX_INIT_CREATE_PROCESS_UTILITY_VM { static inline auto Type = LxInitMessageCreateProcessUtilityVm; using TResponse = RESULT_MESSAGE<uint32_t>; MESSAGE_HEADER Header; unsigned short Rows; // 终端行数 unsigned short Columns; // 终端列数 LX_INIT_CREATE_PROCESS_COMMON Common; // 命令行、工作目录、环境变量、用户名等 } LX_INIT_CREATE_PROCESS_UTILITY_VM, *PLX_INIT_CREATE_PROCESS_UTILITY_VM;对照 session-leader.md 的说明,Common部分携带了创建进程所需的全部细节:命令行、当前目录、环境变量、用户名。而Rows/Columns则直接决定了稍后伪终端(PTY)的初始窗口尺寸。
Windows 侧的发起点在 src/windows/service/exe/WslCoreInstance.cpp(L189 附近),它调用LxssCreateProcess::CreateMessage(LxInitMessageCreateProcessUtilityVm, ...)构造这条消息;而 src/windows/common/interop.cpp 中的校验逻辑(L275)则确认了 interop 反向调用同样复用该消息类型。
Common.Flags中有一组关键的控制标志位(lxinitshared.h L1055-L1065),它们会直接改变 relay 的行为:
| 标志 | 值 | 作用 |
|---|---|---|
LxInitCreateProcessFlagsStdInConsole | 0x1 | stdin 是控制台(接 PTY 主端) |
LxInitCreateProcessFlagsStdOutConsole | 0x2 | stdout 是控制台 |
LxInitCreateProcessFlagsStdErrConsole | 0x4 | stderr 是控制台 |
LxInitCreateProcessFlagsElevated | 0x8 | 以提权身份运行,切换挂载命名空间 |
LxInitCreateProcessFlagsInteropEnabled | 0x10 | 开启 Linux→Windows 的 interop 调用 |
LxInitCreateProcessFlagAllowOOBE | 0x20 | 额外开一个 OOBE(首体验配置)socket |
三、Relay 的诞生:InitCreateProcessUtilityVm 的执行流程
InitCreateProcessUtilityVm(init.cpp L1340-L1455 起)实现了 relay.md 中描述的「relay 建立 hvsocket 通道 → fork 出父子两进程」的完整过程,按源码顺序拆解如下:
3.1 建立 vsock 监听并回报端口号
relay 与 wslservice.exe 之间的通道建立在 hvsocket(virtio-vsock)之上。Linux 侧先创建一个监听 socket 并绑定到任意可用端口,然后把端口号作为事务应答回传给 Windows 服务,由服务侧主动连接过来:
ListenSocket = UtilListenVsockAnyPort(&SocketAddress, Sockets.size()); ... Transaction.SendResultMessage<uint32_t>(SocketAddress.svm_port);要接受的连接数量默认是宏LX_INIT_UTILITY_VM_CREATE_PROCESS_SOCKET_COUNT(lxinitshared.h L166,值为 5);如果消息带有LxInitCreateProcessFlagAllowOOBE标志,则额外追加一个 socket,总数达到 6。
3.2 fork 出 relay 本体
随后执行第一次fork()(L1445):父进程直接返回,回到 session leader 的消息循环继续处理后续请求;子进程被命名为 "Relay",成为 relay 本体的前半段。这就是 relay.md 所说「Relay is a WSL2 Linux process created by a session leader」在代码上的确切含义——relay 并非 exec 出的新程序,而是 init 二进制 fork 后分叉出的进程角色。
子进程继续向下执行时,若带有LxInitCreateProcessFlagsElevated标志,会先调用ConfigSetMountNamespace切换到提权用的挂载命名空间(L1463)。
3.3 接受 wslservice.exe 的连接
relay 按序 accept 服务侧发来的所有 hvsocket 连接(L1473-L1481),随后关闭监听 socket。这些连接最终会被封装为具名通道(见下文「通道布局」小节):
TerminalControlChannel = {{Sockets[3].get()}, "TerminalControl"}; // 由于 wsl.exe 向 wslhost.exe 交接时序列号可能重置,这里忽略序列号 TerminalControlChannel.IgnoreSequenceNumbers(); ControlChannel = {{Sockets[4].get()}, "Control"};注意注释中的一处细节:终端控制通道在 wsl.exe 与 wslhost.exe 之间发生交接(handover)时序列号会重置,所以该通道显式关闭了序列号校验。这也解释了为什么 wslhost.exe 可以接管长驻会话的输入输出。
3.4 interop、管道与 subreaper
在 fork 出最终用户进程之前,relay 还完成了三件准备工作:
- interop 初始化:若
LxInitCreateProcessFlagsInteropEnabled置位且分布配置允许,调用InteropServer.Create()创建 interop 服务器 socket(L1494-L1502)。这是 Linux 进程反向调用 Windows 程序(wslpath、explorer等,见 interop.md)的基础设施,relay 循环中会持续 accept 该 socket 上的请求并经控制通道转发给 Windows。 - 为非控制台标准句柄建管道:对 stdin/stdout/stderr 中凡是标志显示「非控制台」的句柄,分别创建一对
pipe()(L1508-L1548),用于 relay 与用户进程之间的纯字节流传输。 - 把自己标记为 subreaper:
prctl(PR_SET_CHILD_SUBREAPER, 1)(L1554)。从源码结构看,这使 relay 成为其子孙进程孤儿链的最终回收者,保证即使用户 shell 先退出,后台派生进程也不会被丢给 init 而失控。 - 提前阻塞 SIGCHLD:源码注释(L1562-L1565)明确指出,阻塞必须在 fork 之前完成,否则「子进程可能在 relay 来得及 mask 信号之前就退出」,导致 SIGCHLD 丢失。
3.5 forkpty:PTY 与第二次 fork
relay 用forkpty(L1583)一次性完成伪终端创建与第二次 fork,初始窗口尺寸直接取自消息中的Rows/Columns:
WindowSize.ws_col = CreateProcess.Columns; WindowSize.ws_row = CreateProcess.Rows; Result = forkpty(&Master, NULL, NULL, &WindowSize);这正是 relay.md 描述的「relay forks() into two processes」:
- 子进程(用户进程前身):先
sigprocmask(SIG_SETMASK)复位信号掩码,dup(STDIN_FILENO)拿到控制终端 fd;对非控制台句柄逐个dup2到管道读端;然后CreateProcessParseCommon解析命令行/目录/环境变量/用户,若开启 interop 则写入WSL_INTEROP_ENV环境变量指向 interop socket 路径,最后进入CreateProcessCommon完成 uid/gid、工作目录设置并exec()用户命令。 - 父进程(真正的 relay 转发器):关闭不再需要的管道端,把线程名改为
Relay(ChildPid)便于诊断,创建signalfd来捕获 SIGCHLD,并进入核心的 poll 转发循环。
四、五条 hvsocket 通道的布局与职责
relay.md 列出的三组用途——「中转 stdin/stdout/stderr、中转终端信息(如窗口在 Windows 侧被调整大小时)、通知 Windows 侧 Linux 进程已退出」——在代码中落实为 5 个默认 socket(外加可选 OOBE socket)的固定索引布局。relay 父进程的 poll 描述符数组(init.cpp L1746-L1759)给出了最直观的对照:
| poll 槽位 | fd 来源 | 对应通道 | 用途 |
|---|---|---|---|
| [0] | Sockets[0](hvsocket) | stdin | 把 Windows 侧输入转发进 stdin 管道/PTY 主端 |
| [1] | StdOutPipe.read() | stdout 管道 →Sockets[1] | 用户进程 stdout 字节流回传 Windows |
| [2] | StdErrPipe.read() | stderr 管道 →Sockets[2] | 用户进程 stderr 字节流回传 Windows |
| [3] | Master(PTY 主端) | PTY 输出 →Sockets[1]/Sockets[2] | 控制台模式下终端输出(含回显)回传 |
| [4] | InteropServer.Socket() | interop | accept 子进程的 interop 请求并转给控制通道 |
| [5] | SignalFd(signalfd) | SIGCHLD | 子进程退出检测与回收 |
| [6] | Sockets[3](hvsocket) | TerminalControl | 接收LX_INIT_WINDOW_SIZE_CHANGED等终端控制消息 |
其中Sockets[4](Control 通道)不直接参与 poll,专门用于发送/接收带结构的消息,最关键的一条就是进程退出状态。若开启了 OOBE,第 6 个 socket(Sockets[5])会在 fork 前传给子进程侧使用。
五、Relay 父进程的转发循环:poll 驱动的七个事件源
relay 父进程的核心是一个for(;;)循环,每轮poll上述 7 个描述符。下面按事件源逐个说明其中容易被忽略的工程细节。
5.1 stdin 转发与防死锁的 PendingStdin 缓冲
输入方向的数据流是「stdin hvsocket →write(StdIn)」,其中StdIn要么是 PTY 主端的dup(控制台模式),要么是 stdin 管道写端(非控制台模式),并且被设为O_NONBLOCK。当write返回EAGAIN/EWOULDBLOCK或部分写入时,剩余数据不会丢,而是存入PendingStdin,在下一次循环开头优先写出(L1777-L1872)。
源码注释解释了这样做的动机:若 stdin 写入阻塞,而用户子进程恰好阻塞在 stderr/stdout 的写操作上(Windows 侧消费速度慢时),relay 就会被卡死在写 stdin 上,再也读不到子进程的输出,形成死锁。非阻塞 + 暂存缓冲区把这个竞争窗口打开了(L1849-L1859)。
另外,stdin socket 上的 0 字节读表示 Windows 侧关闭了输入,relay 会关闭对应 stdin fd;若 stdin 是控制台,还会一并关闭 PTY 主端(L1827-L1843),使子进程读到 EOF 或 HUP。
5.2 stdout/stderr 与 PTY 主端
- 非控制台管道(槽位 1、2)读出数据后
UtilWriteBuffer直接写回Sockets[1]/Sockets[2];写失败若是EPIPE(Windows 侧已断),就关闭该管道读端并SHUT_WR对应 socket(L1878-L1918)。 - PTY 主端(槽位 3)的读取需要特殊处理:PTY 在挂起(hangup)时返回
EIO而不是 EOF,代码里显式把BytesRead == 0 || errno == EIO都视为结束(L1929-L1945)。根据 StdOutConsole/StdErrConsole 标志,PTY 输出被路由到 stdout 或 stderr socket(L1953-L1960)。
5.3 进程退出检测与退出状态上报
这是 relay「通知 Windows 进程已退出」职责的实现。relay 父进程通过signalfd把 SIGCHLD 变成可读事件(槽位 5),收到后循环waitpid(-1, &Status, WNOHANG)回收僵尸进程(得益于 3.4 节的 subreaper 设置,孤儿孙进程也会落到这里)。一旦回收到的 pid 等于直接子进程ChildPid:
- 用
WIFEXITED/WEXITSTATUS提取退出码; - 经 Control 通道发送
LxInitMessageExitStatus消息并同步等待 Windows 侧确认(L2047-L2070):
ExitStatus.Header.MessageType = LxInitMessageExitStatus; ExitStatus.ExitCode = Status; ControlChannel.SendMessage(ExitStatus); ControlChannel.ReceiveMessage<LX_INIT_PROCESS_EXIT_STATUS>();- 关闭 stdin(
SHUT_RD)与 stdout/stderr(SHUT_WR)socket,并移除终端控制槽位;当waitpid返回ECHILD(再无子进程)时,relay 退出。
退出前还会对所有 socket 做一次幂等的UtilSocketShutdown清理(L2130-L2134),保证 Windows 侧能观测到干净的对端关闭。
5.4 终端尺寸变化:从 Windows 到 TIOCSWINSZ
relay.md 提到的「terminal window is resized from Windows」走的是 TerminalControl 通道(槽位 6)。relay 收到LX_INIT_WINDOW_SIZE_CHANGED消息后,直接对 PTY 主端执行ioctl(TIOCSWINSZ)(L2099-L2121):
auto [Message, _] = TerminalControlChannel.ReceiveMessageOrClosed<LX_INIT_WINDOW_SIZE_CHANGED>(); ... WindowSize.ws_col = Message->Columns; WindowSize.ws_row = Message->Rows; ioctl(Master, TIOCSWINSZ, &WindowSize);内核随后向控制终端发送 SIGWINCH,用户进程(如 bash、vim)据此重排界面——这就是你在 Windows 终端里拖拽窗口、Linux 内ls/vim即时响应的新旧尺寸切换的原理。而「通道关闭(0 字节读)」则意味着 Windows 侧会话结束,relay 随即退出(L2103-L2111)。
5.5 interop 请求的旁路转发
poll 槽位 4 处理子进程发起的 interop 连接:relay accept 后读取MESSAGE_HEADER,再调用ConfigHandleInteropMessage把「Linux 侧想创建 Windows 进程」的请求经 Control 通道转发给 Windows 侧,由 interop.md 描述的机制在 Windows 上落地(L1983-L2008)。这条路径让 relay 除了管 IO,还顺带承担了会话内 interop 消息的代理角色。
六、与 WSL 1 路径的对照:为什么 WSL 2 需要 relay
session-leader.md 给出了对照视角:
- WSL 1:session leader 直接 fork,子进程配置 uid/gid、工作目录、标准文件描述符后
exec()用户进程。进程与 Windows 控制台之间的 IO 通过消息端口/控制台机制直接完成,没有独立的 relay 层。 - WSL 2:session leader fork 出 relay,由 relay 负责创建用户进程并「把输出中转回 Windows」。
从源码结构看,这一差异的根源在于 WSL 2 的用户进程跑在轻量虚拟机的 Linux 内核里,标准 IO 无法像 WSL 1 那样直接映射到 Windows 控制台,必须走 hvsocket 字节流;同时 PTY、终端尺寸、退出状态、interop 都变成需要持续双向同步的状态,于是需要一个专职父进程(relay)常驻在两者之间做 poll 转发——这正是 WSL 1 路径里不存在的职责。
七、小结与延伸阅读
| 关注点 | 结论 | 依据 |
|---|---|---|
| relay 由谁创建 | session leader 处理创建消息时 fork 出的子进程,线程名 "Relay"/Relay(ChildPid) | init.cpp L1445-L1457、L1695 |
| 触发消息 | LxInitMessageCreateProcessUtilityVm,携带 Rows/Columns/Common | lxinitshared.h L1067-L1078 |
| 通道数量 | 默认 5 条 hvsocket(stdin/stdout/stderr/终端控制/控制),OOBE 场景 +1 | lxinitshared.h L166;init.cpp L1405-L1408 |
| 终端尺寸同步 | TerminalControl 通道收LX_INIT_WINDOW_SIZE_CHANGED→ioctl(TIOCSWINSZ) | init.cpp L2099-L2121 |
| 退出通知 | subreaper + signalfd +LxInitMessageExitStatus经 Control 通道回报 | init.cpp L1554、L2014-L2077 |
| 与 wslrelay.exe 的关系 | 完全不同的组件:后者是 Windows 侧 localhost/调试控制台中转 | wslrelay.exe.md、src/windows/wslrelay/main.cpp |
如果要继续深入这条链路的两端,建议阅读仓库内配套文档:上游的 session-leader.md(WSL 1/WSL 2 创建路径的对比)与 wsl.exe、wslservice.exe、wslhost.exe(Windows 侧三者的分工与交接),以及 interop 专题 interop.md。测试侧的 test/windows/wslrelay/ 与 test/windows 下的相关单测(如 SimpleTests.cpp)也可作为验证进程创建与 IO 链路行为的参考。
【免费下载链接】WSLWindows Subsystem for Linux项目地址: https://gitcode.com/GitHub_Trending/ws/WSL
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考