服务器编程之路:进无止境(下)

(接上文)

为了找到第二个命题的解决方法,我们可以再回过头来看看本文中第一版的服务器程序。前面也说了,第一版程序的问题在于,一条线程服务一个连接,而OS切换线程的开销很大,所以造成性能上不去。但第一版程序绝对是愉快的顺序编程。如果我们想保留顺序编程,那应该怎么克服性能方面的缺陷呢?

问题被直接导向为:既然OS调度线程很吃力,那是否存在一种“用户态线程”,由程序自己调度,让OS一边玩儿去?

先抛出答案,所谓的“用户态线程”,我们一般的实现就是“协程(coroutine)”。

在教科书上,协程的定义恐怕是这样的:“协程就是协作的子例程”。啥啥?“协作”是什么意思?“子例程”又是什么鬼?这个定义真是让人云里雾里。

我觉得协程很难用一句简短的语句定义。解释协程这个概念,必须说明以下两点:

1、协程本质上是一种算法。一个函数如果能够实现中断,后续恢复时能够从断点继续照常执行,那么这个函数就可以称为协程。

2、协程与线程的本质区别在于:线程之间是竞争cpu的;而协程之间是相互协作的,互相“禅让”cpu。协程有一个关键的原语“yield”,表示主动把cpu退让出去。yield是由用户在协程中自行调用的,理论上你可以不调用,那么这个协程就独占了整条线程的cpu资源。协程相互yield,就是协程协作的本意。

很多编程语言从语言级别上就天然支持了协程,如C#、Go、Lua。但可惜的是,作为偏底层的C/C++却不在语言中支持。

当然,C/C++可以自己实现协程。Windows有协程API(称为fiber),Linux提供了ucontext.h头文件,它允许使用者保存并回复断点上下文(当前寄存器状态与栈内存),从而实现协程。如果你想了解更多的细节,可以阅读我实现的协程代码:http://github.com/xphh/coroutine

有了协程,离我们的命题“顺序编程”还是有一段距离。事情还只讲了一半。

因为协程毕竟还是在一个线程内的,所以某一个协程阻塞了,别的协程也运行不下去了。也就是说,协程还不能等价于“用户态线程”。想要把协程当做线程用,必须考虑如何把在协程中阻塞的操作变成线程中不阻塞的操作。

“把在协程中阻塞的操作变成线程中不阻塞的操作”,这句话很拗口也很矛盾,很难直接阐释,我只能讲怎么做。

在第一版服务器程序中,我们处理一个连接,接收时往往需要阻塞在recv函数上。但实际上,recv所做的处理,无非是在等IO上的数据,在等待的过程中,cpu是被浪费掉的。所以我们要做的第一件事,就是在recv阻塞之前,yield到一个epoll协程。这个epoll协程监听所有IO,当某个IO(比方刚才那个协程中的IO)有数据时,再resume回到那个IO所在的协程。

在上面我们提到了协程的resume原语。是这样的,协程有两种编程模型,一种叫“对称协程”,其中所有的协程都可以任意yield到另一个协程;另一种叫“非对称协程”,所有协程只能yield到主协程,由主协程利用resume调用某一个协程。一般情况下我们推荐非对称协程,因为对称协程在编程上是混乱的。

这样,整个程序由一个线程的N+1个协程组成。N个协程处理N条连接,一个主协程做IO复用(epoll)。在这N个协程上我们的处理的确是阻塞的,但实际上线程并没有阻塞,线程大部分时间都在主协程的epoll上而已。

也就是说,使用协程顺序编程,我们必须提前处理掉所有的阻塞调用。所有socket的接口(connect、sendto、send、recvfrom、recv)以及sleep函数,都需要自己实现为协程版本,即:阻塞前yield出去,在主协程中等待事件触发后resume回来。

至此,命题二实现!

结束了吗?还没有呢。如果你开始想用协程进行服务器编程,那以下这些事一定得知道:

1、协程只不过是有独立栈空间的线程,如果你在协程中阻塞了,其他协程也阻塞了。

2、关于上一点,我相信大家都清楚了。你肯定会说,不是可以改写阻塞函数吗?的确可以。不过你得明白,C/C++编程发展至今,更多的是一种生态体系。可惜这种生态对协程并不友好。我们几乎必用的服务器开发相关的第三方库,如mysqlclient,libcurl等,项目中用的话,是不可能直接改源码的。所以协程运用的边界,你得考虑清楚。

3、对于多核服务器,提升性能最终还是要靠多线程或多进程。如果你想在多线程中使用协程,记住不要在多个线程中调度协程。也不是说不可以,而是这种做法和协程的出发点是相悖的。

多线程 -> 事件模型 -> 协程,这就是服务器编程之路。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章