背景
据说这是 PG 社区每隔一段时间就被拿出来讨论的一个事情,最近的一次比较广泛的讨论时 Heikki 提出的,同时在 Hacker News 也引起了讨论。
跟踪一下这个讨论的 topic,也学习一下在多进程 vs 多线程的选型时的优劣取舍问题。
这个话题在其它的讨论 DB 的圈子里也偶见讨论。比如最近,看到了两个相反的观点,一个好像是德哥写的(找不到了),吐槽 PG 产品的高傲,因为多进程无法支持大并发连接;另一个是 Pigsty 的冯若航吐槽 OpenGauss 不管不顾上游下游生态扩展直接将多进程改多线程的。
其中冯若航也提到了 PG 社区的讨论,应该就是上面那个 topic。这里看一下部分讨论,看下这个 topic 的各方的观点。
社区讨论原贴内容
Heikki
Heikki 经过和一些人的讨论,认为将多进程改成单进程多线程已经是一种无声的共识了。尽管这种修改可能又很多的细节需要讨论,但是从顶层看并没有反对的理由。
在 PG 的开发者 FAQ 中,有说明为什么不使用多线程,如果这个提议没有太多反对的声音,那么 FAQ 中关于不使用多线程原因的描述就可以删掉了。
几个比较重要的讨论的点:
- 过渡周期
即使内核可以很快改成多线程,但是扩展却需要更多的时间适配,可以先通过 GUC 参数控制使用多进程还是多线程。
- 每个连接的线程
最直接的方式,先把每个连接改成一个线程,用来替代当前的 backend process。未来,可以引用线程池和调度;或者一个连接多个线程,或者 spawn 新的辅助线程。
- 全局变量
当前有很多的全局静态变量。
1 | $ objdump -t bin/postgres | grep -e "\.data" -e "\.bss" | grep -v |
其中,一些全局变量是指向共享内存结构的指针,这些可以保持原样。另外,有很多是每个连接状态下的。最直接的方式,就是将它们改成线程局部存储的。
可能有个比较好的方式是,设计一下可以到处传递的会话上下文,或者使用一个单独的变量持有这个会话上下文。很多之前的全局变量可以变成上下文中的字段。
- 扩展
很多扩展也有全局变量或者其他在多线程环境下容易引起问题的设计。可能需要标志哪些扩展支持多线程,未来所有扩展都应该支持。
当前可以在 control file 中添加标识来说明某个扩展是否支持多线程。如果尝试加载不兼容的扩展,则报错。
我们可能需要在连接建立和销毁的时候添加新的函数 _PG_init
。
- 暴露 PIDs
有几个地方,我们给用户暴露了 PID,比如 pg_stat_activity.pid 和 pg_terminate_backend()。这里需要修改,比如我们可以提供一个 fake PID。
- 信号
当前进程间通信使用的是信号。比如 SIGURG 用于 latches,SIGUSR1 用于 procsignal。信号的使用可以使用其他的信号进制重写。理论上,可以给每个线程单独发送信号(pthread_kill),但是最好重写。
当前,可以使用 SIGINT, SIGTERM 或者 SIGHUP 给单个后端进程发送消息。我们应该使用一些更方便的替代方式。比如,将带有后端 ID 的消息发
送到 unix domain socket,然后使用一个新的 pg_kill 来发送消息。
- 崩溃后重启
如果一个后端进程崩溃了,那么 postmaster 可以结束所有的进程,并且重启系统。在单进程多线程的模型中,这个很难做到。我们可以继续使用一个单独的 postmaster 进程,观察主进程并处理主进程的崩溃。
- 线程安全库
我们需要使用线程安全的库,比如使用 uselocale() 替代 setlocale()。
另外,Python 中有 GIL 锁,没有办法在一个进程中创建两个独立的 Python 解释器。幸运的是,Python 社区接纳了 pep-0684。这之后,就可以创建独立的 Python 解释器,每个解释器有自己独立的 GIL 锁。
大致看了下,perl 和 TCL 应该是可以一个进程中运行多个解释器。其他我们使用的库也应该检查下。
other opinions
- Tom Lane
认为是个灾难,太多的代码会被破坏,而且是悄无声息的,并且其中大部分不在我们控制之下。
- Tristan Partin
1)同意会话上下文的实现比线程局部存储更简单。
2)可以通过 pkg-config 暴露一个参数,来告诉后台是多线程还是多进程的。
3)暴露 PID 是不是可以直接用 thread ID 替换呢?
- Robert Haas
过渡期可能是无限长的,可能也可以考虑不强制要求扩展兼容多线程。
- Greg Stark
提出了部分不知道算不算反对意见,但是认为应该普遍关注的问题。
Stark 认为,线程和进程理论上是同一回事,只是在 API 的表现上不同,即在内存中,进程是默认不共享的,需要显式共享,而线程是默认共享的,需要显式不共享。有些明显的 API 的不同,比如信号的处理,但是这些都是实现细节。
(Andres Freund回复这一段:理论上是这样的,但是在实践中,则是完全不对的。
跨进程,也就是 fork 之后,分享状态是很复杂的。比如,可以在多进程间传递 FD,但是这非常依赖于平台;可以在 fork 之后共享内存,但是不同进程并不是相同的指针值。
更重要的是,线程和进程存在着很大的性能差异,拥有共同的内存映射,使得多线程可以共享 TLB,而在多进程下这是不可能的。)
所以问题就变成了,是使用共享内存更好还是使用不共享内存更好,以及实现细节上的重要性是否超过这种好处。
共享内存的设计,会引起复杂的数据结构的设计和混乱。共享数据的所有权、谁负责更新、错误处理、资源释放,通常比较宽松。
因此,拥有一个良好的基础功能来显式清晰地申请和管理内存是比较好的。
但是这里讨论了是都是其他的,比如使用 ptheads 的 mutex 和 条件变量 来替代我们自己的锁。
需要考虑一下,是否值得冒着风险引入不清晰的数据结构所有权。
同意多进程和多线程同时支持的做法,但是这样也失去了使用多线程的好处,我们还是没有办法使用 pthreads, 还是得用我们自己编写的基础功能设施。
(Andres Freund回复这一段:可能不是这样的。比如,我们可以最终决定,在并行查询中只使用多线程,这使得我们减少大量的代码和运行时开销。)
- Ashutosh Bapat
多进程可以使用所有可能的核心,但是单进程多线程可以吗?这可能取决于 OS 和架构。
一个可能的好的开始是,使用线程先替代并行任务,比如并行的 vacuum,并行的查询之类的,但是连接进程和 leader 进程还是保留,然后慢慢完全转向多线程模型。根据 Bapat 对其他产品的经验,最终可能是 多进程-多线程 的模型。
- Andres Freund
关于扩展,一个有趣的事是,如何检测扩展支持多线程还是多进程,比如,检查 Linux 系统上,扩展有多少个可读可写的全局变量。
Freund 不认为在控制文件中标记是否支持多线程是一个好主意,可能通过 PG_MODULE_MAGIC 更好。
同意应该有一个单独的线程管理。
Hannu Krosing
Krosing 列出了 FAQ 中之前关于为什么不使用多线程的论述,并且逐条进行了分析。
FAQ:历史上看,线程的支持不太好并且有很多问题。
Krosing:曾经是这样,现在已经支持得很好了,并且问题也很少。FAQ: 同一进程中的线程的 error 会相互影响。
Krosing: 对于无法检测的 error,依旧是这样的;但是对于可检测的,我们都是要重启所有的 backends 的。FAQ:相对于 backend 启动的时间,多线程带来的速度提升很小。
Krosing:现在有一些测量显示,在和启动无关的场景里,有显著的性能提升。
Hekki 回复:不期望切换成多线程直接带来很大的性能提升,但是切换成多线程使得许多其他的事情得到帮助,比如 shared catalog cache。FAQ:backend 的代码会变得更加复杂。
Krosing:1)依旧是问题。2)更麻烦的是,扩展需要重写。3)很多不兼容的场景无法立即发现,需要很长的时间。FAQ:backend 以进程方式结束,可以让操作系统清理所有的资源,避免资源泄露,使得 backend 停止的时候代价更小更快。
Krosing:依旧是这样。
Heikki回复:不太担心 PG 本身的代码,因为内存上下文和资源 owner 能够避免泄露。但是第三方的库可能有这个问题,但是多进程模型对这个问题更加宽容。FAQ:debugging 多线程比多进程更难,并且 coredump 文件帮助更小。
Krosing:这个被大家反驳,因为 1)现在 debugger 有对多线程的支持。2)当前没有直接的 debugger 工具,可以用来调试 pg 这种多进程+共享内存的模式。FAQ:共享只读的二进制映射段以及 shared buffer 的使用,使得多进程像多线程一样高效。
Krosing:有一些相反的观点。1)多进程虚拟内存的映射,会加大 RAM 的使用。2)per-backend 的缓存,比如 pg_catalog 以及 statement 的缓存,更容易在多线程中实现。3)每一次进程切换时 TLB 加载的成本很高,在多线程中则不需要。FAQ:多进程的创建和销毁,有利于减少内存碎片,而在长时间运行的进程中,则很难管理。
Krosing:可能依旧是这样的。