相信绝大部分开发者都知道“设计模式”(英文为Design Patterns)。设计模式很好,让我们在设计和开发软件模块的时候为实现“高内聚,低耦合”等目标提供了强有力的指导。
不过,在我的体会中,设计模式还是更“静态”——它比较关注设计好类、接口、类之间的关系。值得指出的是,设计模式是需要有一定开发经历和经验的人才需要看的。
以我自己为例,我是工作三年后才知道有设计模式得。看完GoF设计模式一书后,理论水平就上升了,以后再看到代码里的Adapter、Factory、Proxy、Observer等词汇就大概知道是什么意思了。
但在我之前和后来的软件开发实践中,我发现还有一类也和程序设计有关的知识比设计模式更加基础和重要。并且,这个知识实际上从每个人第一天coding开始就存在。比如下面这个场景:
从上述的1到2再到3,我们可以明显发现程序的结构在演进。这种演进是追随一种思路或者更直白得讲是有一定套路的,这个套路就是我刚才说除了设计模式之外的另外一种模式方面的知识了——它更倾向于关注程序工作时的“动态”。
有了这方面的知识,不需要设计模式(或者在不太方便使用设计模式的领域,比如用汇编,脚本写个程序),你也能更好得设计出一个运行良好的程序。这个套路中最核心和基础的知识就是本文要介绍的线程和I/O模型。当然,有一套叫《Patterns Of System Architecture》书你可以看,它有5本之多。
接下来我会介绍线程和I/O模型方面的知识。它们看起来非常简单。但我要提醒各位的是,最重要的并不是你记住了这些知识,而要理解模型演化的推动力——其实是靠问题驱动的。没有问题,就无需演化。并且,每种模型都有适用场景,并不是最后的模型就是最合适的模型。
所以,通过这篇文章,你最终要明白 模型演化的历史以及促使它演化的问题和解决办法。
现在网上有很多文章介绍多线程方面的知识,比如Android上的AsyncTask之类的,它们都很好,但如果能了解到整个线程模型的演化历史,我相信你的整体认识会上升一个台阶。
而且,负责任得讲,虽然IT发展飞快,但这些套路几乎没有太多的变化——这也是为何设计模式到今天还很流行的原因。历史是没有答案的未来,未来的知识很可能就在你对历史的回顾中就已经掌握了。
先来看一个视频,这是我用PPT做的,然后保存为mp4。视频只有不到3分钟,但是包含8个部分(视频地址在https://mp.weixin.qq.com/s/qodCngOPXGSaaBy2ULAgqg)。
上面视频里介绍了8个部分,分别是:
每个部分都很短。各位先看,接下来我会逐个介绍。等大家整体了解了,再回头看这个视频,跟着动画过一遍演化历史及相关问题就行了。
接着来文字+图片来继续讲述我们的故事。
下面是Thread Loop的演化。请按照图中的数字序号的顺序来看。
我们写了一个程序,其中主线程有一段逻辑,叫Thread Loop①。它的演化是这样的:
以上就是线程Thread Loop的演化过程。直到今天,程序架构也脱离不了这些个架构。并且,模型③④⑤在本质上差不多,都是要等待一个事情的发生(无论这个事情是I/O事件,还是消息,还是什么别的玩意)。
接着来仔细看看上面的模型④,线程需要搭配一个消息队列(Message Queue)。怎么让这个搭配消息队列的线程工作呢?来看下图:
这个图中:
站在消息队列的角度看,线程A是消息的消费者,而线程B或OS是消息的生产者。这个模型是不是非常简单?
但各位Android同学想想,这么简单的东西,反映在Android里,不就是MessageQueue、Message、Handler和HandlerThread吗(HandlerThread就是线程A+消息队列)。如果知道这个模型,还会在面试的时候被人问倒么.....
上面这个线程模型还能进化出更高级的玩法,比如:
不论哪种高级玩法,最终都是在上面这个模型上附加的操作。
上面都是单个线程自己玩,现在来看看线程池。
图片里首先解释了为什么要使用线程池“”以及“什么时候使用线程池”。假设你已经确定要用线程池了,那么有两个关键问题需要事先确定好①:
上面两个问题在Java Concurrent库也是替你做好了分类处理:
这两个问题很关键,但第一个问题其实很多年前就有了一点参考性得答案。根据大量测试,人们得到了一个经验值——线程个数约等于CPU核数的2倍。这个经验值我不确定在应用开发中是不是也适合,反正是有这么一个说法。
现在我们有了线程池,事情还没完,还要演化。现在面临的问题是,我有这么多线程,我怎么让它们工作呢?来看下图:
这里再次借助了生产者和消费者模型:
惊群效应是使用线程池一个要重点注意的问题——你值得知道有。
到此,和消息队列有关的线程模型演化史告一段落。现在我们看看I/O模型的演化史。
上图中我们以socket I/O为例,因为它是比较典型的I/O操作场景。线程A在自己的Thread Loop里做accept客户连接以及read客户发过来的数据①。这个模型有什么问题呢?有两个非常明显的问题②:
怎么办?模型演化的推动力就是解决碰到的问题,不要把这个事情想得太难,我们经常是头疼医头脚疼医脚。比如上面两个问题的解法。最开始想到的解决办法是把阻塞改成非阻塞。
上图中,我们把accept和read改成了非阻塞(③),结果又引入了新的问题(④):
这算是一个馒头引发的血案吗?没有关系,新问题正是模型演化的推动力,我们看看I/O模型演化是如何解决上面两个问题的。
针对阻塞I/O、非阻塞I/O引发的忙等问题,我们发现问题的本质原因其实是在于不知道发生什么事情了。参考前面的Message Queue,如果我们知道发生什么事情了,然后针对这些事情做对应的处理,不就完美了吗?来看下图(序号接上图):
I/O模型演化到此就得到一个里程碑式的模型——I/O多路复用(Multiplexing I/O):
和I/O多路复用相关的有select、poll和epoll。这是Linux平台上最为高效的I/O模型了。而且,select、poll、epoll都是一个东西,只不过具体实现上epoll更高效一点而已(可以大胆猜测,select一定是要出现的,然后因为效率问题才演化出了epoll。由于epoll非常完美,所以几十年过去了,epoll还没有出现新的替代者)。
忙等问题通过I/O多路复用模型解决了,但是read耗时——也就是一个人忙不过来的问题却解决不了。其实,这个问题其实用刚才说的线程/线程池就能轻松解决。来看下图:
解决一个人忙不过来的问题很简单:
到此,Linux上I/O模型的演化就告一段落。但演化的推动力还在继续。
上面反复说了,演化的推动力是新问题的出现。I/O多路复用在Linux平台上效率极其优秀,但在Windows平台上,这个模型的效率还是有很大的问题。问题不是出在I/O模型本身上,而是Windows在网络栈里埋了很多Hook,各种Hook,严重影响了I/O效率。
所以,Windows平台上演化了I/O模型的最高级形势——异步I/O,Windows行叫I/O Completion Port(简写为IOCP,中文叫I/O完成端口)。可惜的是,Windows上的IOCP的效率依然不如Linux上的I/O多路复用。
来看看异步I/O模型是什么。
异步I/O模型比较复杂,要用好非常麻烦,但理解并不困难:
以上就是异步I/O模型的核心。我记得很多年前Linux系统是不支持socket使用异步I/O的,现在再看manual socket,发现好像也有对异步I/O支持的蛛丝马迹。不管怎样,异步I/O模型大概就是这个样子。
最后说几点,异步I/O模型比较难理解,代码不好写....。没想清楚这一点的人是幸运的,如果不是强制要用的话就不讨论了。
Kotlin引入了协程的概念。我个人觉得除非像JavaScript、go、dart这种从一开始就没有线程概念的语言可以搞“协程”,Kotlin在明知道Java里是有线程概念的情况下还搞个协程是有点意思了(是好是坏我感觉自己还评价不了....)。
我们来回顾下历史。早期,存在所谓的User Thread——即用户态的线程和所谓的Kernel Thread——kernel线程,真正被kernel用来调度的线程,也就是Java中的Thread、Posix中的Thread。另外,历史上甚至还有一个Light Weight Process(LWP)的概念。
User Thread和Kernel Thread的关系有两种:
早期,不同的线程库(比如Posix线程库)使用了不同的对应关系,有使用1-on-1的,也有使用M-on-N的。大名鼎鼎的POSIX线程库在发展过程中也是从M-on-N到1-on-1。下面是官方霸气回应什么最终会选择1-on-1:
所以,仅从并发、异步操作这个角度来看,协程(包括kotlin、dart、go)其实是又回归到了M-on-N的情况。当然,在现在的软硬件环境下,协程可能会比给开发人员直接暴露OS线程操作API要方便许多。毕竟,线程是一种重要资源,并发逻辑要写好还是有一定难度的。
不过我现在依然觉得既然java早就完美支持线程了,而且还有线程池,还有强大的并发库。Kotlin搞个协程是有点奇怪了。有同学说Kotlin协程能减少线程上下文切换的损耗——讲真:
神农和朋友们的杂文集
长按识别二维码关注我们