☰
WSL 2 Relay 进程解析:Linux 用户进程如何被创建,以及 Windows 与 Linux 之间的 IO 如何双向中转
2026/10/17 17:13:59 网站建设 项目流程

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 的完整生命周期由三段代码串起来:

  1. src/linux/init/init.cpp 中的SessionLeaderEntryUtilityVm(约 L3125)——session leader 的消息循环,等待创建进程请求;
  2. 同文件中的InitCreateProcessUtilityVm(约 L1340)——WSL 2 下创建用户进程的核心入口,relay 即在此 fork 出来;
  3. 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 的行为:

标志值作用
LxInitCreateProcessFlagsStdInConsole0x1stdin 是控制台(接 PTY 主端)
LxInitCreateProcessFlagsStdOutConsole0x2stdout 是控制台
LxInitCreateProcessFlagsStdErrConsole0x4stderr 是控制台
LxInitCreateProcessFlagsElevated0x8以提权身份运行,切换挂载命名空间
LxInitCreateProcessFlagsInteropEnabled0x10开启 Linux→Windows 的 interop 调用
LxInitCreateProcessFlagAllowOOBE0x20额外开一个 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()interopaccept 子进程的 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:

  1. 用WIFEXITED/WEXITSTATUS提取退出码;
  2. 经 Control 通道发送LxInitMessageExitStatus消息并同步等待 Windows 侧确认(L2047-L2070):
ExitStatus.Header.MessageType = LxInitMessageExitStatus; ExitStatus.ExitCode = Status; ControlChannel.SendMessage(ExitStatus); ControlChannel.ReceiveMessage<LX_INIT_PROCESS_EXIT_STATUS>();
  1. 关闭 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/Commonlxinitshared.h L1067-L1078
通道数量默认 5 条 hvsocket(stdin/stdout/stderr/终端控制/控制),OOBE 场景 +1lxinitshared.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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询