GHC IO管理器的进化历程

December 14, 2013

为了更好地理解IO Manager做的事,我觉得有必要了解运行时中相关的部分,于是按时间循序看了些论文。因为GHC的IO Manager才是主题的,每一节都是对应论文的笔记,不是特别相关的内容(调度、STM、spark)都被简单略过了1

最初的N:1模型

Paper2: Concurrent Haskell

   [HaskellThread]
---------------------
      OSThread

轻量级线程

  • 一个Haskell程序使用一个OS线程(native thread),在这之上实现用户态的轻量级线程
  • 每个线程都有一个堆上分配的栈
  • 调度器只在特定的时刻换线程:堆上的对象不是正在修改,知道寄存器的liveness信息

局限

一个线程执行阻塞IO时会阻塞全部线程。

FFI和混合模型

Paper: Extending the Haskell FFI with Concurrency

FFI和并发

通过FFI,可以Haskell里调用其他语言的函数(foreign out-call),也可以在其他语言调用Haskell函数(foreign in-call)。out-call又分为safeunsafe

  • safe:可以回调Haskell函数,需要更多的维护实现回调
  • unsafe:不可回调Haskell函数,性能更好

FFI和Concurrent Haskell交互的时候会遇到的一些问题:

  1. out-call可能阻塞
  2. in-call/out-call可能要求在同一个OS线程上进行(例如使用了thread local state)
  3. 可能有并发的in-call
  4. in-call可能产生新的Haskell线程

一些要求和解决方法:

  1. safe out-call只阻塞发起调用的Haskell线程
  2. 区分bound和unbound的Haskell线程,绑定线程上的FFI必须在同一个OS线程上执行
  3. in-call时产生一个绑定的Haskell线程
  4. 主线程是绑定的

GHC的实现

GHC的通过N:M+1:1的混合模型实现了上面的要求。

  [UnboundHaskellThread]        BoundHaskellThread
--------------------------   -------------------------
      [OSThread]                    OSThread
  • 每个绑定线程在对应的一个OS线程上执行
  • 非绑定线程可以在若干个OS线程迁移、执行
  • 每个OS线程都有各自的调度器
  • 在某一时刻,通过一个叫Capability的锁保证,只有一个OS线程能执行Haskell代码(不管是否绑定),但多个OS线程可以同时执行call-out(非Haskell代码)。
  • 一个OS线程在执行Haskell代码时,在下面的情况下要释放Capability:
    • safe call-out(避免阻塞其他非绑定线程)
    • 另一个OS线程上执行的call-out返回了 (FFI优先级更高)
    • 另一个OS线程被call-in(FFI优先级更高)
    • 要执行的是绑定线程且被绑定在另一个OS线程,直接把Capability传给目标OS线程
    • 当前进程用于执行绑定线程,但遇到了非绑定线程,直接把Capability传给目标OS线程
    • 在该OS线程上的绑定线程结束
  • 释放或传递Capability后,OS线程会再尝试获得,如果没获得就堵塞
  • 因为只有一个Capability,同时要保证unsafe的性能,unsafe call-out会阻塞全部Haskell线程

IO Manager

在上面的实现中,只要把阻塞的call-out标记为safe,就能避免IO阻塞全部的Haskell线程,但是每个safe call-out都会占用一个OS线程,性能比不上用poll/select + non-blocking IO。GHC的IO Manager通过更简单的方法封装了poll/select 和non-blocking IO,向非绑定线程提供简单的接口(绑定线程会在独立的OS线程执行就没必要用IO Manager了)。

首先会有一个专门执行poll/select的非绑定线程(IO服务线程),它会监听一组文件描述符,其中一个文件描述符用于通知自己。

当一个非绑定线程需要等待IO(例如read返回 EAGAIN)时:

  1. 把要操作的文件描述符放到一个全局的MVar
  2. 通知IO服务线程 (向通知文件描述写1字节的控制消息)
  3. 线程阻塞在一个MVar上,等待IO服务线程通过这个MVar唤醒自己

IO服务进程循环进行下面的过程,提过IO服务:

  1. 从全局MVar取得文件描述符
  2. 在这些描述符上进行poll/select(safe out-call),阻塞直到返回
  3. 如果通知用的描述符可读,从通知用的描述符读1字节
  4. 通过线程提供的MVar通知它们文件描述符可读或可写

每个safe out-call都需要占用一个OS线程,如果同时有大量阻塞的safe out-call就需要大量的线程。所以当safe out-call要使用的文件描述符能用poll/select等待时,可以通过IO Manager把同时存在的OS线程数大大减少(至少有一个执行Haskell代码,一个来执行poll/select,可读或可写时再用别的OS线程进行非阻塞IO),代价是增加了OS线程间的通信和全局MVar的瓶颈。

不再是一个Capability!

根据一节的描述,同一时刻只有一个OS线程在执行Haskell代码,Haskell程序根本没有充分利用到多核,于是Capability不再是锁,也不仅有一个……Capability成为虚拟机一样的存在,里面有很多Haskell线程,一个Capability需要一个OS线程来运行。总之现在能在多个OS线程上同时执行Haskell代码了,上面一些细节就变了例如:

  1. unsafe call-out只会阻塞一个OS线程和所在的Capability。

我们还是回到IO Manager直接相关的好了。

事件驱动IO

Paper: Scalable I/O Event Handling for GHC

这个IO Manager被称为new IO Manager,主体是EventManager,管理底层的事件和回调(回调函数通常是个写MVar的闭包,用来唤醒线程)。

  1. 把底层的poll/select换成事件驱动的epoll/kqueue
  2. 改善通知方式,例如Linux下用eventfd
  3. 用一个基于Patricia树的Map保存文件描述符到线程的映射,加快查找
  4. 用priority search queue保存timeout
  5. 很多小优化

虽然原理和用法没太大变化,但是性能的提升和扩展性都是真的。

New IO Manager的问题,可以说是从最初的IO Manager就遗留下来的问题:只有一个IO Manager,在高并发时会成为瓶颈。

Mio

Paper: Mio: A High-Performance Multicore IO Manager for GHC

我总想起秋山澪

对The new IO Manager的改进:

  1. N个EventManager(N等于Capability个数)
  2. 每个EventManager都有一个并发的查找表,里面有32个带锁的Map,根据文件描述符,把回调函数哈希到一个Map
  3. 每个Capability有一个分发线程(IO服务线程),加快消息分发,减少核心间的交互
  4. 对事件注册优化

题外:论文里给出nginx和mighttpd的性能对比,感觉要比Wrap科学,nginx的性能不是盖的。

并不特别

你看Java用1:1模型,然后通过Executor、fork/join把任务细分然后分派到工作线程,还不是活很好的。Python用Gevent也是差不多效果,C直接epoll+coroutine也一样牛逼(lawn),连PHP都能借ibev/libevent的东风了(React),HHVM甚至也实现了类似的服务器,GHC的轻量级线程其实没什么特别的,GHC IO Manager其实就是个non-blocking IO + IO multiplexing封装。

我也一直在想,Haskell有什么独一无二的、有用的特性是其他语言难以模仿的?我现在能感受到的,更多的是函数式的好,而不是牛逼的语言特性和运行时。


  1. 有些内容在后面的论文没提到,实际上多半已经改变,特别是第一篇论文的内容,为了避免混乱,在我弄好折叠前,那些改变了又没提到的都被注释掉了,可以在HTML里看到。

  2. paper到底是怎样的概念?