手动点赞, 太厉害了
我准备写个总结再发个帖子,不过发了那我就直接回复 。
关于 Windows 上最大子进程数量的限制的相关帖子应该是 @junmoxiao 的一系列:
- 2024 年 2 月发现 native comp 会触发 too many open files 报错 关于windows下emacs报错Creating process pipe: Too many open files的真正解决方法,他尝试直接修改
FD_SETSIZE
来提高子线程数量限制,但实际上没有效果。 - 2024 年 6 月, 优化windows版本emacs的总结(持续更新 2024-2-16) 和 关于优化windows版本emacs的进展(2024-6-16更新,绕过了子进程/套接字数量限制) ,通过多线程实现了允许等待超过 32 个子进程。
- 2024 年 6 月向 emacs-devel(I broke through the limitation of only 32 child processes that can be cr ) 和 bug-gnu-emacs (bug#71628: sys_select and waitpid optimize in windows emacs )发送邮件。
虽然向 bug-gnu-emacs 发送了邮件,但似乎始终没有收到来自 FSF 的文件,也就无法继续整个提交流程(我问了下,原来一直在邮件垃圾箱里 )。在二五年春节的时候我花了一点时间在他的代码的基础上进行了一些改善,然后完成了代码的提交,相关的邮件讨论主要是:
- 2025-01 Increase FD_SETSIZE in w32.h to allow more than 32 (actually 29) subproc
- 2025-02 Re: Increase FD_SETSIZE in w32.h to allow more than 32 (actually 29) sub
- 2025-03 Re: Increase FD_SETSIZE in w32.h to allow more than 32 (actually 29) sub
问题
Windows 上并没有 select
,或者说只有用于网络的且 FD_SETSIZE
被设定为 64。要等待对象需要使用 WaitForSingleObjec
或 WaitForMultipleObjects
。WaitForMultipleObjects
支持的最大等待对象数量是 64,要想等待超过 64 个对象要么在多个子线程中调用 WaitForMultipleObjects
要么使用 Windows 的线程池。先前版本的 Emacs 中直接使用了单个 WaitForMultipleObjects
,所以最多 32 个子进程。
但是实际上最多开不到 32 个子进程,这和实际的进程创建过程有点关系,打开和子进程通信的管道时会开两个分别用于输入和输出,然后分别关掉两个管道中的一个文件描述符,也就是说打开一个进程时首先打开四个文件描述符然后再关闭两个。由于限制到 64 个描述符且标准输入/输出/错误还占三个,实际可用的数量要小于 64,因此最多只能创建 29 个。
为了解决这个问题,我们需要多线程,或者直接线程池一步到位。
2025-01
在最初的讨论中,我提到可以使用一些未公开的 IOCP API 来实现大量对象的等待,但 Eli 的回复是尽量不要使用未公开的 API,而且希望在不大量引入改变的情况下允许创建更多的子进程。
在 1 月 24 我提交了一个初步的没有注释的补丁,在这个最初的实现中每次调用等待函数时会生成用于等待的线程,并在等待结束后直接结束这些线程。这会带来额外的开销。我当时的测试结果是一旦开始创建子线程,单次调用的时间开销会从几微秒变成几百微秒,当等待一千个左右的子进程时单词 accept-process-output
差不多需要 4 毫秒。在我的机器上创建一个线程(指创建线程到线程实际开始执行之间的时间)用时测试如下:
total: 34.581500 ms, average: 0.540336 ms
max : 1.429200 ms, min : 0.039000 ms
线程的创建用时大概是在几十到几百微秒之间。
当等待对象的数量较多时,单次 sys_select
调用的时间量级是毫秒似乎是不太能接受的。为了尽量消除线程启动和销毁的开销,需要一个简单的线程池。
2025-02
Windows 在 XP 还是 Windows 2000 就有系统可用的线程池了: New Windows 2000Pooling Functions GreatlySimplify Thread Management-- MSJ, April 1999。从 XP 到 Vista API 似乎有过一次大的更新。在著名的 The Old New Thing 系列博客中可以找到有关线程池的内容:
- Why bother with RegisterWaitForSingleObject when you have MsgWaitForMultipleObjects? - The Old New Thing
- All Windows threadpool waits can now be handled by a single thread - The Old New Thing
前篇表示线程池内部使用了多线程调用 WaitForMultipleObjects
来等待多个对象,后一篇则说明从 Windows8 开始线程池内部会使用 IOCP 而不是多线程 WaitForMultipleObjects
进行等待,后者的效率比前者高得多(不过这些使用 IOCP 的 API 不是公开的)。
直接使用线程池的一个问题是线程池“直到” Windows 2000 才被支持,因此它不支持先前的 Windows 版本。另一个问题是线程池的线程使用进程的默认栈大小,在 Emacs 中这是 8MB。在 64 位系统中这通常不是一个问题,但 32 位的 Windows 中用户进程的默认地址空间只有 2GB。创建大量子进程会使用较多地址空间,而调整线程池栈大小的 API 在 Windows 7 中才出现: SetThreadpoolStackInformation function (threadpoolapiset.h)。
出于简单考虑我就手动实现了一个比较简陋的线程池,它只负责等待而没有什么消息回调,所以代码量不大,去掉注释可能 500 行不到。如果在 Windowss 8 及以上系统使用系统线程池的话效率应该更高,不过我没有进一步测试和对比了。我在 emacs-devel 上发送补丁文件时也发送了测试代码来测试等待不同对象的用时,根据代码输出的数据可以在 Excel 中绘制如下曲线: 允许 Windows 上的 Emacs 创建超过 32 个子进程(最多 1021 个) - Emacs-general - Emacs China
在进程数量小于 32 时默认采用单个 WaitForMultipleObjects
调用,这个之前不支持更多子进程的 Emacs 行为一致,当线程数量超过 32 时可以观察到等待时间有一个从差不多 1 微秒到 10 微秒的跳变,从 100 个等待对象开始,差不多每 64 个对象(对应 32 个子进程)有一个小的时间跳变,整体的趋势是线性的。到最大等待对象数量(2048左右)时等待时间在 130 微秒左右。我的机器使用的 CPU 是六核十二线程的 7600HS,在我朋友的 13700f(16核24线程)上等待 2048 对象时只有 68 微秒,也许在 32 线程以下 CPU 核心越多等待时间越短。
我在 2月11日 收到了 Cecilio Pardo 的测试反馈,似乎没有什么问题。
2025-03
在差不多一个月后,我的 FSF 签名总算是完成了,这个补丁最终完成提交 ,commit 是 e02466a。NEWS 在 21371aa:
---
** Emacs on MS-Windows now supports up to 1024 sub-processes.
Changes in implementation of monitoring sub-processes allow Emacs on
MS-Windows to start up to 1024 sub-processes, similar to GNU/Linux and
other free systems.
这个补丁有什么用
对日常 Emacs 使用应该没有什么影响,不会加速子进程启动。如果计算机核心较多(比如 32 核 64 线程),在启用 native-compile 时应该不会因为创建编译子进程过多而报错;或者是在打开一堆项目后不会因为启动了过多的 LSP 服务器而报错。和先前实际只能创建 29 个子进程类似,现在实际也最多只能创建 1021 个子进程。
由于更复杂的等待逻辑,即使没有创建子进程,accept-process-output
的立即调用(指定时间为 0)用时从零点几微秒变成了大概 1.5 微秒,不过这应该无关紧要。即使在等待 1000 多个子进程需要上百微秒的时间,这也与 Windows 默认时间片的 15.625ms(64HZ)相比起来微不足道,不会有什么太大的影响。
真要改善 Emacs 上的子进程启动性能,也许等 WASM 组件模型成熟了而且各运行时都更能用的时候,可以把单个 WASM 模块当“进程池”用,也就不用什么“进程镂空”的黑客手段。(也许可以在 Emacs 里面内置一个 WASM 运行时?)
番外:Windows 时间片问题
在研究 WaitForMultipleObjects
,我发现如果等待时间过短,从等待开始到等待失败的时间会超过指定的毫秒数。这主要是因为等待失败后线程可能要等到下一个时间片才会被调度,Windows 上的默认时间片是 15.625ms,所以如果指定等待时间为 1ms,等待超时后可能总等待时间是 15ms 左右。Windows 提供了设置时间片的 API: timeBeginPeriod 。可以看看 Bruce Dawson 的一系列博客:
似乎有人认为 Windows 的默认时间片不是 15.625ms: 2024 年了,怎么还有人在复读/造谣 15.625 ms 时间片? - 知乎,这个提问者似乎写了一篇这样的内容:
我认为桌面/服务器 Windows 系统早在 2014 年时的主流设置就已经是 1 ms 了(彼时,笔电以 10 ms 为主流),而不是 2004 年的,以 15.625 秒为主——而就在最近,我发现知乎上仍然充斥着 15.625 ms 的这个说法。这个值如果不搞清楚,那么一些代码的可靠性就无法估计,算法用到的参数也无法决定。
作者(也就是那个问题的提问者)似乎认为现在 Windows 的时间片不是 15.625ms,而是 1ms 或者 1ms,不过他没有在文章中给出,而是在评论区:
Azure 上 VM 节能专门改的。15.625 ms 的实机很久前就停产了。你看最小值到多少,1 ms 的是老机器,0.5 ms 的是新机器。
参考 Microsoft 文档,时间片应该在 20ms 左右:
The length of the time slice depends on the operating system and the processor. Because each time slice is small (approximately 20 milliseconds), multiple threads appear to be executing at the same time.
在 Bruce Dawson 的博客中他提到了可以使用 ClockRes 观察当前系统的时间片,我在我的 Win10 机器(22H2)上看到的结果是:
Sysinternals
Maximum timer interval: 15.625 ms
Minimum timer interval: 0.500 ms
Current timer interval: 1.000 ms
在管理员权限的 cmd
中执行 powercfg -energy duration 5
后,可以看到以下内容:
在关闭一系列软件后,再次运行 clockres 可以得到:
Sysinternals
Maximum timer interval: 15.625 ms
Minimum timer interval: 0.500 ms
Current timer interval: 9.000 ms
实际上这个数值在 1,8.5,9ms 等多个之间变动。不过观察 powercfg
输出的报告,默认时间片还是 15.625ms。
考虑到 Chrome 系列浏览器的 setTimeout 精度大概是 7ms,我关闭了浏览器再测试:
Sysinternals
Maximum timer interval: 15.625 ms
Minimum timer interval: 0.500 ms
Current timer interval: 15.625 ms
根据 Bruce Dawson 所说,在 2004 版本之后,timeBeginPeriod
的影响会更小,但也更复杂:
The short version is that calls to timeBeginPeriod from one process now affect other processes less than they used to. There is still an effect, and thread delays from Sleep and other functions may be less consistent than they used to be (see [updated] section below), but in general processes are no longer affected by other processes calling timeBeginPeriod .
Windows Timer Resolution: The Great Rule Change | Random ASCII
综上,Windows 的默认时间片还是 15.625ms,但是 timeBeginPeriod
可以修改时间片,在不同的 Windows 上有不同的影响,考虑到浏览器的大量使用,也许可以说系统默认时间片不再是 15.625ms 了,但上面我们说到的那个知乎提问以及提问人自己发的文章的内容似乎有点问题 。
以上
感谢 @junmoxiao, Eli, Cecilio, Po Lu 对这个 commit 的帮助。
niubiliy
不明觉厉,zsbd
厉害,花了不少精力啊
确实,这是我第一次写多线程代码
为了写这个我第一次在 Windows 上学 gdb。
卧槽,这太艰难了
太细了
有时间的话如果能优化下windows下的emacs就更好了