关于协程的学习 & 线程栈默认10M

先看的这篇文章:http://blog.csdn.net/qq910894904/article/details/41699541

以nginx为代表的事件驱动的异步server正在横扫天下,那么事件驱动模型会是server端模型的终点吗?
我们可以深入了解下,事件驱动编程的模型。
事件驱动编程的架构是预先设计一个事件循环,这个事件循环程序不断地检查目前要处理的信息,根据要处理的信息运行一个触发函数。其中这个外部信息可能来自一个目录夹中的文件,可能来自键盘或鼠标的动作,或者是一个时间事件。这个触发函数,可以是系统默认的也可以是用户注册的回调函数。

事件驱动程序设计着重于弹性以及异步化上面。许多GUI框架(如windows的MFC, Android的GUI框架),Zookeeper的Watcher等都使用了事件驱动机制。未来还会有其他的基于事件驱动的作品出现。

基于事件驱动的编程是单线程思维,其特点是异步+回调。

协程也是单线程,但是它能让原来要使用异步+回调方式写的非人类代码,可以用看似同步的方式写出来。它是实现推拉互动的所谓非抢占式协作的关键。

 

总结

协程的好处:

  • 跨平台
  • 跨体系架构
  • 无需线程上下文切换的开销
  • 无需原子操作锁定及同步的开销
  • 方便切换控制流,简化编程模型
  • 高并发+高扩展性+低成本:一个CPU支持上万的协程都不是问题。所以很适合用于高并发处理。

缺点:

  • 无法利用多核资源:协程的本质是个单线程,它不能同时将 单个CPU 的多个核用上,协程需要和进程配合才能运行在多CPU上.当然我们日常所编写的绝大部分应用都没有这个必要,除非是cpu密集型应用。
  • 进行阻塞(Blocking)操作(如IO时)会阻塞掉整个程序:这一点和事件驱动一样,可以使用异步IO操作来解决

 

https://www.zhihu.com/question/32218874

协程方便是因为“用代码来表示状态,而不是维护一坨数据结构来表示状态”,则客观得多。

作者:Tim Shen
链接:https://www.zhihu.com/question/32218874/answer/56255707
来源:知乎
著作权归作者所有,转载请联系作者获得授权。

讲点历史吧。

早期的Unix时代崇尚同步编程,当时设计的时候也没多想,管他什么性能不性能,先做出来再说。所以最开始人是开多进程(process)再用管子(pipe)连接起来的;多直观啊,我把东西推进管子里,你走你那头接一下。

不知道哪天人注意到开个千儿八百个进程机器就卡死了,这怎么行,所以我们只搞一个进程,里面有一坨打开的网络连接和文件,用select这个系统调用对io事件同时进行监听,谁先来我就处理谁。然而发现性能也不好,没什么卵用。

人们在这两条路上都想办法提升性能。进程这边人们又搞了多线程,结果还是不够。多线程还是吃内核资源,抗不住。

Linux忍不住了,搞出个epoll(不过我不知道和bsd的kqueue哪个在前)系统调用,就是红黑树改良版的select。这下子人开心了,真他妈快啊,开千儿八百个连接小意思,同时进行事件监听,判断连接的的id和事件的类型,再打(dispatch)到不同的回调函数里。

不知是不是市场原因,正巧这时候互联网开始火了,性能越好赚钱越多嘛,所以肯定来这套高效的接口,管他好不好用呢,反正俺们吃苦耐劳,外加抖M,才不怕手动维护状态。你多线程开太多最后不也是要手动维护状态。

不熟悉操作系统的开发人员们只知道有什么用什么,也没去多想怎么优化多线程。现在多线程优化出来了,叫协程,调度器有的有(比如go的用户态调度器)有的没有(比如yield),取决于怎么用。其内存开销仍然比异步回调要大(一个协程一个栈,而异步回调的话一个event loop一共就一个栈),但是现在内存也是便宜了,不算是瓶颈。人最关注的是“千万不能堵着(block)”,要千方百计让CPU转起来,这样concurrency才能上去。而到处乱开协程(因为比线程便宜啊)就能达到这个效果,开十万个协程内存也没爆炸,跑上24个就能把CPU打满。所以异步回调的这个优势已经没了。

 

我注意到从事应用开发和网络服务开发的人员对于底层可能没那么熟悉,有时候不免走了弯路。kqueue/epoll这么个简单的东西,被开发者们搞出花来了,异步编程事件回调一套一套的,写书的也有,写库的也有(最近那个reactive也是)。自然术业有专攻,但是倘若稍微花些精力过一遍底层概念,学习/发明上层东西的时候会快很多,也少些痛苦。

至于趋势什么的,也就赶个时髦吧,这些枝叶伎俩学学也要不了几个小时。如果是担心要花很多精力学习某个“技术”,又怕学了没用,故有“是不是趋势啊”这一问,那多半是根基不牢,打打基础就行了。

我是写C++的,我发现我唯一用callback/closure/lambda的地方是我有个函数,但是我想把这个函数扣个洞,让用户来填这个洞。用得还真不算多。

注:之前想学Java的时候,花了很多时间来了解趋势。现在想想,的确是瞻前顾后呀,学一下就知道趋势了,哈哈。

 

------另一个人的回答---------

有时候总觉得 gevent(python的一个库) 这类协程库是被 python 的 GIL 逼出来的,如果原生线程支持足够好,协程的必要性可能并不一定很大。

协程最早来自高性能计算领域的成功案例,协作式调度相比抢占式调度而言,可以在牺牲公平性时换取吞吐。

在互联网行业面临 C10K 问题时,线程方案不足以扛住大量的并发,这时的解决方案是 epoll() 式的事件循环,nginx 在这波潮流中顺利换掉 apache 上位。同一时间的开发社区为 nginx 的成绩感到震撼,出现了很多利用事件循环的应用框架,如 tornado / nodejs,也确实能够跑出更高的分数。而且 python/ruby 社区受 GIL 之累,几乎没有并发支持,这时事件循环是一种并发的解放。

然而事件循环的异步控制流对开发者并不友好。业务代码中随处可见的 mysql / memcache 调用,迅速地膨胀成一坨 callback hell。这时社区发现了协程,在用户态实现上下文切换的工具,把 epoll() 事件循环隐藏起来,而且成本不高:用每个协程一个用户态的栈,代替手工的状态管理。似乎同时得到了事件循环和线程同步控制流的好处,既得到了 epoll() 的高性能,又易于开发。甚至通过 monkey patch,旧的同步代码可以几乎无缝地得到异步的高性能,真是太完美了。

注:callback hell可以看:http://www.infoq.com/cn/articles/nodejs-callback-hell/
Node.js需要按顺序执行异步逻辑时一般采用后续传递风格,也就是将后续逻辑封装在回调函数中作为起始函数的参数,逐层嵌套。这种风格虽然可以提高CPU利用率,降低等待时间,但当后续逻辑步骤较多时会影响代码的可读性,结果代码的修改维护变得很困难。根据这种代码的样子,一般称其为"callback hell"或"pyramid of doom",本文称之为回调大坑,嵌套越多,大坑越深。

注:monkey patch可以看:http://www.tuicool.com/articles/2aIZZb

monkey patch指的是在运行时动态替换,一般是在startup的时候. (感觉大多数是Python程序的一种技巧)

用过gevent就会知道,会在最开头的地方gevent.monkey.patch_all();把标准库中的thread/socket等给替换掉.这样我们在后面使用socket的时候可以跟平常一样使用,无需修改任何代码,但是它变成非阻塞的了.

之前做的一个游戏服务器,很多地方用的import json,后来发现ujson比自带json快了N倍,于是问题来了,难道几十个文件要一个个把import json改成import ujson as json吗?

其实只需要在进程startup的地方monkey patch就行了.是影响整个进程空间的.

同一进程空间中一个module只会被运行一次.

下面是代码.

main.py

import json
import ujson
def monkey_patch_json():
    json.__name__ = 'ujson'
    json.dumps = ujson.dumps
    json.loads = ujson.loads

monkey_patch_json()
print 'main.py',json.__name__
import sub
sub.py
import json
print 'sub.py',json.__name__
运行main.py,可以看到都是输出'ujson',说明后面import的json是被patch了的.

最后,注意不能单纯的json = ujson来替换.

 

然而,跑了一圈回来,协程相比原生线程又有多少差别呢。

1. 用户态栈,更轻量地创建“轻量线程”;
2. 协作式的用户态调度器,更少线程上下文切换;
3. 重新实现 mutex 等同步原语;

协程的创建成本更小,但是创建成本可以被线程池完全绕开,而且线程池更 fine grained,这时相比线程池的优势更多在于开发模型的省力,而不在性能。此外,"轻量线程" 这个名字有一定误导的成分,协程作为用户态线程,需要的上下文信息与系统线程几乎无异。如果说阻碍系统线程 scale 的要素是内存(一个系统线程的栈几乎有 10mb 虚拟内存,线程的数量受虚拟地址空间限制),那么用户态线程的栈如果使用得不节制,也需要同量的内存。
注:线程栈的默认大小可以通过 ulimit -s 来查看。
$ ulimit -s    
10240

也就是10M;修改的方法如下:

linux查看修改线程默认栈空间大小 ulimit -s

1、通过命令 ulimit -s 查看linux的默认栈空间大小,默认情况下 为10240 即10M

2、通过命令 ulimit -s 设置大小值 临时改变栈空间大小:ulimit -s 102400, 即修改为100M

3、可以在/etc/rc.local 内 加入 ulimit -s 102400 则可以开机就设置栈空间大小

4、在/etc/security/limits.conf 中也可以改变栈空间大小:

#<domain> <type> <item> <value>

* soft stack 102400

重新登录,执行ulimit -s 即可看到改为102400 即100M
 
协作式调度相比抢占式调度的优势在于上下文切换开销更少(但是差异是否显著?)、更容易把缓存跑热,但是也放弃了原生线程的优先级概念,如果存在一个较长时间的计算任务,将影响到 IO 任务的响应延时。
内核调度器总是优先 IO 任务,使之尽快得到响应。此外,单线程的协程方案并不能从根本上避免阻塞,比如文件操作、内存缺页,这都属于影响到延时的因素。

事件循环方案被认识的一个优势是可以避免上锁,而锁是万恶之源。协程方案基于事件循环方案,似乎继承了不用上锁的优势。然而并不是。上下文切换的边界之外,并不能保证临界区。该上锁的地方仍需要上锁。
 
差异存在,但该维护的信息并没有更少。如果运行时对系统线程的支持比较好,业务系统使用协程的综合效益并不一定相比线程池更好。我们业内通常意义上的"高并发",往往只是要达到几k qps,然而 qps 是衡量吞吐而非并发的指标(并发1k意味着同时响应1k个连接,而 qps 衡量一秒响应多少请求,这可以是排队处理,并不一定"并发"),靠线程池并非做不到。但对 python 这类 GIL 运行时而言,这却拥有显著提升性能的优势了,只是这时瓶颈在 GIL,而不在线程。

至于并发量导向的业务,一般也是状态上下文较少的业务,比如推送,这时 callback hell 基本可控,使用协程相比事件循环依然更容易编程,但效益并不显著。


最后尝试总结一下个人的想法:


协程不是趋势,它是一个在历史中被挖掘出来的、对现有问题的一个有用的补充。

适用的场景:
  • 高性能计算,牺牲公平性换取吞吐;
  • 面向 IO Bound 任务,减少 IO 等待上的闲置,这其实和高性能计算领域内的优势是一致的;
  • Generator 式的流式计算;
  • 消除 Callback Hell,使用同步模型降低开发成本的同时保留更灵活控制流的好处,比如同时发三个请求;这时节约地使用栈,可以充分地发挥 "轻量" 的优势;
但并不是万灵丹:
  • 如果栈使用得不节制,消耗的内存量和系统线程无异,甚至内存管理还不如系统线程(系统线程可以动态地调整虚拟内存,用户线程的 Segmented Stack 方案存在严重的抖动问题,Continous Stack 方案管理不当也会抖动,为了避免抖动则成了空间换时间,而内核在这方面做了多少 heuristic 呢);
  • IO Bound 任务可以通过调线程池大小在一定程度上缓解,目标是把 CPU 跑满即可,这点线程池的表现可能不完美,但在业务逻辑这个领域是及格的;
  • 此外,一般的 python/ruby 任务并不是严格的 IO Bound,比如 ORM 的对象创建、模版渲染、GC 甚至解释器本身,都是 CPU 大户;单个请求扣去 redis 请求和数据库请求的时间,其它时间是否仍不少呢?
  • CPU 上长时间的计算,导致用户线程的调度变差,不能更快地响应,单个请求的平均时间反而可能更长(诚然并发可能更高);然而这在 python 这类 GIL 语言来看并不算劣势,甚至比 GIL 的调度更好,至少 gevent 可以知道各 IO 任务的优先级,而 GIL 的调度是事实上的 FIFO;

References

- Xiao-Feng Li: Thread mapping: 1:1 vs M:N
-
posted @ 2016-12-16 11:38  blcblc  阅读(1690)  评论(0编辑  收藏  举报