用户态并发:事件驱动
上篇末尾有个地方说错了,分时调度的yield过程应该是:
env.running_queue.add(this);
this.stat = STAT_YIELD;
return;
需要将this加入到running_queue,否则这个线程就死了
有了分时调度,就可以实现计算密集型的程序的并发执行,不过绝大多数程序显然不是这种,程序多多少少都会进入阻塞等待,比如IO,锁,sleep等,这些我们统一认为是事件,阻塞实际就是在等待事件
简单说,用户态的事件驱动,就是建立一个事件表,其中每个事件有一个等待的线程列表,由虚拟机主循环检测事件是否触发(上篇代码中注释部分有),如果触发则调度到等待线程,具体调度方式很简单,移动到running_queue即可,具体的,事件可分为以下几类:
一、虚拟机内部产生的事件 这类事件是虚拟机执行时,内部一些机制产生的,这类事件一般也不用在虚拟机主循环里面主动检测,发生的时候直接操作env,将需要调度的线程加入running_queue即可,最典型的例子上篇已经实现了,线程的join等待是在被等待线程结束的时候主动通知所有在等待的其他线程。其余的类似事件还有信号量等内核对象(这个内核当然指虚拟机) P.S.关于线程join这个问题,上篇的实现还是有问题的,比如A线程先结束,然后B线程join A,应该拿到结果,因此上篇的实现还是简单了,线程的结果应该存在一个地方供其他线程join,join的时候如果被join的线程已经结束,则应立即返回,否则阻塞等待事件
二、定时器事件 某个线程在运行时如果需要暂停一段时间,则需要等待定时器事件,具体的做法是弄一个定时器队列(一般是优先队列,hash轮等算法),当一个线程需要暂停时,增加一个定时器,yield,然后在虚拟机主循环中检测当前已超时的定时器,并调度到对应线程。定时器的一个问题是,由于虚拟机本身运行比较慢,分时调度的粒度也可能比较大,因此不是很精确,当然os的sleep实际也不精确,这个问题控制在可接受范围之内即可
三、中断事件 一般意义的中断一般是指硬件层到操作系统的,这里讨论虚拟机,这类事件就只有一种了:信号。如果虚拟机进程收到一个信号,比如SIGINT,虚拟机自己是不用负责信号的业务处理的,而是由源语言注册信号handler,但是虚拟机需要保证信号不能打断正常运行,具体的做法,在收到信号后,虚拟机可以记录在一个待处理信号表中,然后在主循环中检测,如果检测到上个虚拟时间片收到了信号,则调度执行主线程,并且让主线程立即去执行对应的handler(不能虚拟机自己直接调handler,因为handler里面要用到主线程环境) 可能有人有疑问,为何一定要在主线程执行,实际上不同平台对于多线程程序收到信号的实现是不一样的,有的平台是打断主线程,有的是随机deliver给一个线程,在具体的虚拟机中,设计为由主线程处理相对合理一点
四、阻塞IO事件 当某个线程需要操作低速IO(高低速IO的概念参考APUE),比如socket的读写等,可能阻塞住,这种情况下,就需要线程在可能阻塞的时候先yield,在IO事件到来以后再继续执行,这个做起来有点复杂: 首先,所有的可能阻塞的IO操作都需要改成非阻塞的方式,比如从socket收数据:
data, error += recv_from_socket(s, need_len);
if (error == EWOULDBLOCK)
{
//这里的t是当前线程,类似系统调用的接口不推荐实现在Thread类里面
env.io_event_table.add(t, s, EVENT_TYPE_READ); //注册读事件通知
... //yield;
}
然后,在虚拟机的主循环中需要对所有IO事件做监控,IO事件来自于底层操作系统,一般可以用select或epoll来监控,若事件到达,则调度至对应线程
对于IO事件,其实像上面这么实现还是比较麻烦的,如果从另一个角度考虑,IO操作是一个系统调用,而对虚拟机上跑的程序而言,所谓“系统”其实就是虚拟机,因此可以在虚拟机上做一个代理层,应用层的IO请求由代理层完成,然后返回给应用层,这样代理层就可以做一些合理的包装,比如: send(s, data);
假设这是一个在os的系统调用,则一般实现为当前能发送多少数据就先发送多少数据,send返回值为实际发送量,而我们实际上是希望将data都发出去,如果暂时发不出,就阻塞,则我们需要一个sendall:
void sendall(Socket s, String data)
{
while (data.size() > 0)
{
int send_len = send(s, data);
data = data.substr(send_len, data.size());
}
}
代理层可以将这个做了,和手工实现sendall的区别是,虚拟机在send可能阻塞时会自己调度。类似的还有recvall等需求,可以给源语言的程序设计带来一些便利。具体实现上就是,一个线程告诉虚拟机,我要发送(或接收)xxx字节的数据,然后虚拟机干完这事(或中途出错)后将结果通知线程,由虚拟机代理IO,当然代理的方式可以自由设计,比如另开一个真线程来搞。另开一个IO真线程可以减少虚拟机设计的耦合度,数据收发超时等也容易实现,不至于和虚拟机自己的定时器混在一起
解决上述四个事件驱动后,功能上基本就全了,但还有一些很重要的细节问题:
一、虚拟机本身也是需要阻塞的,否则一个程序跑起来CPU就100%,大部分时间都在轮询检查事件,肯定让人无法接受,虚拟机的阻塞需要跟源语言的阻塞逻辑一致,具体的,只有当所有线程都进入等待事件状态时,虚拟机阻塞,阻塞的实现一般是在select或epoll中,阻塞时间则根据最近一个定时器来决定
二、由于线程join,信号量等机制是在虚拟机层模拟操作系统实现(为提高效率),对应的一些死锁检测也必须虚拟机来做了,检测算法参考操作系统原理即可
三、惊群效应,如果多个线程等待同一事件,则需要根据具体情况决定是全部唤醒还是唤醒一个,比如线程结束事件应该广播通知所有正在join的线程,而互斥锁的解锁事件则只能通知一个正在等待的线程
四、和上面第三条相反,有时候一个线程在同时等待多个事件,最典型的例子是,当一个线程recv或send的时候,指定一个超时时间,或者等待一个锁的时候指定超时时间,或者源语言提供了select之类的接口,监控多个IO事件 如果多个事件是冲突的,可以通过事件关联来做,比如加锁超时的需求,同时注册锁对象的等待和定时器,在事件管理器里面用一个group将两者关联,只要其中一个触发了,则从另一个的等待队列删除对应线程,避免出问题 如果多个事件不冲突,比如select,我们希望在select返回时尽可能多的得知当前已准备好的IO事件,则可能需要在检测了所有事件后进行汇总,然后再根据事件关联来调度线程 不过话说回来,如果一个语言是用户态调度的,则select一般用的也比较少,因为可以每个IO事件开一个线程来监控,反正用户态的线程非常廉价,如果多个IO事件之间有关联,也可以通过线程间通讯来实现(虚拟机可以实现类似的阻塞管道,并和IO事件绑在一起)
这里的三四还可能组合在一起,比如一个线程正在对socket sa,sb的可读做select,另一个正在读sb,那么sa,sb同时可读时该怎么通知呢,这可能就是一个实现相关的问题了,不过,一个socket被两个线程同时操作,本身就可能产生未定义行为,一般都是需要避免的
其实我原本没想写并发相关的内容,因为大部分都是跟操作系统相关,不如去看操作系统原理,使用GIL等技术,在语言实现中实现多线程也不困难。但是多线程环境可能会导致GC之类的机制的复杂性,而类似的方面我打算只在单线程的前提下讨论,因此讲了用户态并发,语言实现并发完全可以不依赖宿主的多线程环境,这也算是语言实现的一种简化方案了