同一个同进程的线程可以共享多个线程堆栈共享状况哪个描述正确

在 SegmentFault,解决技术问题
每个月,我们帮助 1000 万的开发者解决各种各样的技术问题。并助力他们在技术能力、职业生涯、影响力上获得提升。
一线的工程师、著名开源项目的作者们,都在这里:
获取验证码
已有账号?
标签:至少1个,最多5个
进程和线程究竟是什么东西?传统网络服务模型是如何工作的?协程和线程的关系和区别有哪些?IO过程在什么时间发生?
在刚刚结束的 PyCon2014 上海站,来自七牛云存储的 Python 高级工程师许智翔带来了关于 Python 的分享《Python中的进程、线程、协程、同步、异步、回调》。
一、上下文切换技术
在进一步之前,让我们先回顾一下各种上下文切换技术。
不过首先说明一点术语。当我们说“上下文”的时候,指的是程序在执行中的一个状态。通常我们会用调用栈来表示这个状态——栈记载了每个调用层级执行到哪里,还有执行时的环境情况等所有有关的信息。
当我们说“上下文切换”的时候,表达的是一种从一个上下文切换到另一个上下文执行的技术。而“调度”指的是决定哪个上下文可以获得接下去的CPU时间的方法。
进程是一种古老而典型的上下文系统,每个进程有独立的地址空间,资源句柄,他们互相之间不发生干扰。
每个进程在内核中会有一个数据结构进行描述,我们称其为进程描述符。这些描述符包含了系统管理进程所需的信息,并且放在一个叫做任务队列的队列里面。
很显然,当新建进程时,我们需要分配新的进程描述符,并且分配新的地址空间(和父地址空间的映射保持一致,但是两者同时进入COW状态)。这些过程需要一定的开销。
忽略去linux内核复杂的状态转移表,我们实际上可以把进程状态归结为三个最主要的状态:就绪态,运行态,睡眠态。这就是任何一本系统书上都有的三态转换图。
就绪和执行可以互相转换,基本这就是调度的过程。而当执行态程序需要等待某些条件(最典型就是IO)时,就会陷入睡眠态。而条件达成后,一般会自动进入就绪。
当进程需要在某个文件句柄上做IO,这个fd又没有数据给他的时候,就会发生阻塞。具体来说,就是记录XX进程阻塞在了XX fd上,然后将进程标记为睡眠态,并调度出去。当fd上有数据时(例如对端发送的数据到达),就会唤醒阻塞在fd上的进程。进程会随后进入就绪队列,等待合适的时间被调度。
阻塞后的唤醒也是一个很有意思的话题。当多个上下文阻塞在一个fd上(虽然不多见,但是后面可以看到一个例子),而且fd就绪时,应该唤醒多少个上下文呢?传统上应当唤醒所有上下文,因为如果仅唤醒一个,而这个上下文又不能消费所有数据时,就会使得其他上下文处于无谓的死锁中。
但是有个著名的例子——accept,也是使用读就绪来表示收到的。如果试图用多个线程来accept会发生什么?当有新连接时,所有上下文都会就绪,但是只有第一个可以实际获得fd,其他的被调度后又立刻阻塞。这就是惊群问题thundering herd problem。
现代linux内核已经解决了这个问题,方法惊人的简单——accept方法加锁。
(inet_connection_sock.c:inet_csk_wait_for_connect)
线程是一种轻量进程,实际上在linux内核中,两者几乎没有差别,除了一点——线程并不产生新的地址空间和资源描述符表,而是复用父进程的。
但是无论如何,线程的调度和进程一样,必须陷入内核态。
二、传统网络服务模型
为每个客户分配一个进程。优点是业务隔离,在一个进程中出现的错误不至于影响整个系统,甚至其他进程。Oracle传统上就是进程模型。缺点是进程的分配和释放有非常高的成本。因此Oracle需要连接池来保持连接减少新建和释放,同时尽量复用连接而不是随意的新建连接。
为每客户分配一个线程。优点是更轻量,建立和释放速度更快,而且多个上下文间的通讯速度非常快。缺点是一个线程出现问题容易将整个系统搞崩溃。
py_http_fork_thread.py
在这个例子中,线程模式和进程模式可以轻易的互换。
如何工作的:
父进程监听服务端口
在有新连接建立的时候,父进程执行fork,产生一个子进程副本
如果子进程需要的话,可以exec(例如CGI)
父进程执行(理论上应当先执行子进程,因为exec执行的快可以避免COW)到accept后,发生阻塞
上下文调度,内核调度器选择下一个上下文,如无意外,应当就是刚刚派生的子进程
子进程进程进入读取处理状态,阻塞在read调用上,所有上下文均进入睡眠态
随着SYN或者数据报文到来,CPU会唤醒对应fd上阻塞的上下文(wait_queue),切换到就绪态,并加入调度队列
上下文继续执行到下一个阻塞调用,或者因为时间片耗尽被挂起
同步模型,编写自然,每个上下文可以当作其他上下文不存在一样的操作,每次读取数据可以当作必然能读取到。
进程模型自然的隔离了连接。即使程序复杂且易崩溃,也只影响一个连接而不是在整个系统。
生成和释放开销很大(效率测试的进程fork和线程模式开销测试),需要考虑复用。
进程模式的多客户通讯比较麻烦,尤其在共享大量数据的时候。
thread模式,虚拟机:
1: 909.27 2: : : : : : : : 1778.26 (出现错误)
fork模式,虚拟机:
1: 384.14 2: 435.67 3: 435.17 4: 437.54 10: 383.11 50: 364.03 100: 320.51 (出现错误)
thread模式,物理机:
1: : : : : : : 2377.77
注意在python中,虽然有GIL,但是一个线程陷入到网络IO的时候,GIL是解锁的。因此从调用开始到调用结束,减去CPU切换到其他上下文的时间,是可以多线程的。现象是,在此种状况下可以观测到短暂的python CPU用量超过100%。
如果执行多个上下文,可以充分利用这段时间。所观测到的结果就是,只能单核的python,在小范围内,其随着并发数上升,性能居然会跟着上升。如果将这个过程转移到一台物理机上执行,那么基本不能得出这样的结论。这主要是因为虚拟机上内核陷入的开销更高。
三、C10K 问题
当同时连接数在10K左右时,传统模型就不再适用。实际上在效率测试报告的线程切换开销一节可以看到,超过1K后性能就差的一塌糊涂了。
进程模型的问题:
在C10K的时候,启动和关闭这么多进程是不可接受的开销。事实上单纯的进程fork模型在C1K时就应当抛弃了。
Apache的prefork模型,是使用预先分配(pre)的进程池。这些进程是被复用的。但即便是复用,本文所描述的很多问题仍不可避免。
线程模式的问题
从任何测试都可以表明,线程模式比进程模式更耐久一些,性能更好。但是在面对C10K还是力不从心的。问题是,线程模式的问题出在哪里呢?
有些人可能认为线程模型的失败首先在于内存。如果你这么认为,一定是因为你查阅了非常老的资料,并且没仔细思考过。
你可能看到资料说,一个线程栈会消耗8M内存(linux默认值,ulimit可以看到),512个线程栈就会消耗4G内存,而10K个线程就是80G。所以首先要考虑调整栈深度,并考虑爆栈问题。
听起来很有道理,问题是——linux的栈是通过缺页来分配内存的(How does stack allocation work in Linux?),不是所有栈地址空间都分配了内存。因此,8M是最大消耗,实际的内存消耗只会略大于实际需要的内存(内部损耗,每个在4k以内)。但是内存一旦被分配,就很难回收(除非线程结束),这是线程模式的缺陷。
这个问题提出的前提是,32位下地址空间有限。虽然10K个线程不一定会耗尽内存,但是512个线程一定会耗尽地址空间。然而这个问题对于目前已经成为主流的64位系统来说根本不存在。
内核陷入开销?
所谓内核陷入开销,就是指CPU从非特权转向特权,并且做输入检查的一些开销。这些开销在不同的系统上差异很大。
线程模型主要通过陷入切换上下文,因此陷入开销大听起来有点道理。实际上,这也是不成立的。线程在什么时候发生陷入切换?正常情况下,应当是IO阻塞的时候。同样的IO量,难道其他模型就不需要陷入了么?只是非阻塞模型有很大可能直接返回,并不发生上下文切换而已。
效率测试报告的基础调用开销一节,证实了当代操作系统上内核陷入开销是非常惊人的小的(10个时钟周期这个量级)。
线程模型的问题在于切换成本高
熟悉linux内核的应该知道,近代linux调度器经过几个阶段的发展。
linux2.4的调度器
O(1)调度器
实际上直到O(1),调度器的调度复杂度才和队列长度无关。在此之前,过多的线程会使得开销随着线程数增长(不保证线性)。
O(1)调度器看起来似乎是完全不随着线程的影响。但是这个调度器有显著的缺点——难于理解和维护,并且在一些情况下会导致交互式程序响应缓慢。
CFS使用红黑树管理就绪队列。每次调度,上下文状态转换,都会查询或者变更红黑树。红黑树的开销大约是O(logm),其中m大约为活跃上下文数(准确的说是同优先级上下文数),大约和活跃的客户数相当。
因此,每当线程试图读写网络,并遇到阻塞时,都会发生O(logm)级别的开销。而且每次收到报文,唤醒阻塞在fd上的上下文时,同样要付出O(logm)级别的开销。
O(logm)的开销看似并不大,但是却是一个无法接受的开销。因为IO阻塞是一个经常发生的事情。每次IO阻塞,都会发生开销。而且决定活跃线程数的是用户,这不是我们可控制的。更糟糕的是,当性能下降,响应速度下降时。同样的用户数下,活跃上下文会上升(因为响应变慢了)。这会进一步拉低性能。
问题的关键在于,http服务并不需要对每个用户完全公平,偶尔某个用户的响应时间大大的延长了是可以接受的。在这种情况下,使用红黑树去组织待处理fd列表(其实是上下文列表),并且反复计算和调度,是无谓的开销。
四、多路复用
要突破C10K问题,必须减少系统内活跃上下文数(其实未必,例如换一个调度器,例如使用RT的SCHED_RR),因此就要求一个上下文同时处理多个链接。而要做到这点,就必须在每次系统调用读取或写入数据时立刻返回。否则上下文持续阻塞在调用上,如何能够复用?这要求fd处于非阻塞状态,或者数据就绪。
上文所说的所有IO操作,其实都特指了他的阻塞版本。所谓阻塞,就是上下文在IO调用上等待直到有合适的数据为止。这种模式给人一种“只要读取数据就必定能读到”的感觉。而非阻塞调用,就是上下文立刻返回。如果有数据,带回数据。如果没有数据,带回错误(EAGAIN)。因此,“虽然发生错误,但是不代表出错”。
但是即使有了非阻塞模式,依然绕不过就绪通知问题。如果没有合适的就绪通知技术,我们只能在多个fd中盲目的重试,直到碰巧读到一个就绪的fd为止。这个效率之差可想而知。
在就绪通知技术上,有两种大的模式——就绪事件通知和异步IO。其差别简要来说有两点。就绪通知维护一个状态,由用户读取。而异步IO由系统调用用户的回调函数。就绪通知在数据就绪时就生效,而异步IO直到数据IO完成才发生回调。
linux下的主流方案一直是就绪通知,其内核态异步IO方案甚至没有被封装到glibc里去。围绕就绪通知,linux总共提出过三种解决方案。我们绕过select和poll方案,看看epoll方案的特性。
另外提一点。有趣的是,当使用了epoll后(更准确说只有在LT模式下),fd是否为非阻塞其实已经不重要了。因为epoll保证每次去读取的时候都能读到数据,因此不会阻塞在调用上。
用户可以新建一个epoll文件句柄,并且将其他fd和这个"epoll fd"关联。此后可以通过epoll fd读取到所有就绪的文件句柄。
epoll有两大模式,ET和LT。LT模式下,每次读取就绪句柄都会读取出完整的就绪句柄。而ET模式下,只给出上次到这次调用间新就绪的句柄。换个说法,如果ET模式下某次读取出了一个句柄,这个句柄从未被读取完过——也就是从没有从就绪变为未就绪。那么这个句柄就永远不会被新的调用返回,哪怕上面其实充满了数据——因为句柄无法经历从非就绪变为就绪的过程。
类似CFS,epoll也使用了红黑树——不过是用于组织加入epoll的所有fd。epoll的就绪列表使用的是双向队列。这方便系统将某个fd加入队列中,或者从队列中解除。
要进一步了解epoll的具体实现,可以参考这篇linux下poll和epoll内核源码剖析。
如果使用非阻塞函数,就不存在阻塞IO导致上下文切换了,而是变为时间片耗尽被抢占(大部分情况下如此),因此读写的额外开销被消除。而epoll的常规操作,都是O(1)量级的。而epoll wait的复制动作,则和当前需要返回的fd数有关(在LT模式下几乎就等同于上面的m,而ET模式下则会大大减少)。
但是epoll存在一点细节问题。epoll fd的管理使用红黑树,因此在加入和删除时需要O(logn)复杂度(n为总连接数),而且关联操作还必须每个fd调用一次。因此在大连接量下频繁建立和关闭连接仍然有一定性能问题(超短连接)。不过关联操作调用毕竟比较少。如果确实是超短连接,tcp连接和释放开销就很难接受了,所以对总体性能影响不大。
原理上说,epoll实现了一个wait_queue的回调函数,因此原理上可以监听任何能够激活wait_queue的对象。但是epoll的最大问题是无法用于普通文件,因为普通文件始终是就绪的——虽然在读取的时候不是这样。
这导致基于epoll的各种方案,一旦读到普通文件上下文仍然会阻塞。golang为了解决这个问题,在每次调用syscall的时候,会独立的启动一个线程,在独立的线程中进行调用。因此golang在IO普通文件的时候网络不会阻塞。
五、事件通知机制下的几种程序设计模型
使用通知机制的一大缺憾就是,用户进行IO操作后会陷入茫然——IO没有完成,所以当前上下文不能继续执行。但是由于复用线程的要求,当前线程还需要接着执行。所以,在如何进行异步编程上,又分化出数种方案。
用户态调度
首先需要知道的一点就是,异步编程大多数情况下都伴随着用户态调度问题——即使不使用上下文技术。
因为系统不会自动根据fd的阻塞状况来唤醒合适的上下文了,所以这个工作必须由其他人——一般就是某种框架——来完成。
你可以想像一个fd映射到对象的大map表,当我们从epoll中得知某个fd就绪后,需要唤醒某种对象,让他处理fd对应的数据。
当然,实际情况会更加复杂一些。原则上所有不占用CPU时间的等待都需要中断执行,陷入睡眠,并且交由某种机构管理,等待合适的机会被唤醒。例如sleep,或是文件IO,还有lock。更精确的说,所有在内核里面涉及到wait_queue的,在框架里面都需要做这种机制——也就是把内核的调度和等待搬到用户态来。
当然,其实也有反过来的方案——就是把程序扔到内核里面去。其中最著名的实例大概是微软的http服务器了。
这个所谓的“可唤醒可中断对象”,用的最多的就是协程。
协程是一种编程组件,可以在不陷入内核的情况进行上下文切换。如此一来,我们就可以把协程上下文对象关联到fd,让fd就绪后协程恢复执行。
当然,由于当前地址空间和资源描述符的切换无论如何需要内核完成,因此协程所能调度的,只有在同一进程中的不同上下文而已。
这是如何做到的呢?
我们在内核里实行上下文切换的时候,其实是将当前所有寄存器保存到内存中,然后从另一块内存中载入另一组已经被保存的寄存器。对于图灵机来说,当前状态寄存器意味着机器状态——也就是整个上下文。其余内容,包括栈上内存,堆上对象,都是直接或者间接的通过寄存器来访问的。
但是请仔细想想,寄存器更换这种事情,似乎不需要进入内核态么。事实上我们在用户态切换的时候,就是用了类似方案。
C coroutine的实现,基本大多是保存现场和恢复之类的过程。python则是保存当前thread的top frame(greenlet)。
但是非常悲剧的,纯用户态方案(setjmp/longjmp)在多数系统上执行的效率很高,但是并不是为了协程而设计的。setjmp并没有拷贝整个栈(大多数的coroutine方案也不应该这么做),而是只保存了寄存器状态。这导致新的寄存器状态和老寄存器状态共享了同一个栈,从而在执行时互相破坏。而完整的coroutine方案应当在特定时刻新建一个栈。
而比较好的方案(makecontext/swapcontext)则需要进入内核(sigprocmask),这导致整个调用的性能非常低。
协程与线程的关系
首先我们可以明确,协程不能调度其他进程中的上下文。而后,每个协程要获得CPU,都必须在线程中执行。因此,协程所能利用的CPU数量,和用于处理协程的线程数量直接相关。
作为推论,在单个线程中执行的协程,可以视为单线程应用。这些协程,在未执行到特定位置(基本就是阻塞操作)前,是不会被抢占,也不会和其他CPU上的上下文发生同步问题的。因此,一段协程代码,中间没有可能导致阻塞的调用,执行在单个线程中。那么这段内容可以被视为同步的。
我们经常可以看到某些协程应用,一启动就是数个进程。这并不是跨进程调度协程。一般来说,这是将一大群fd分给多个进程,每个进程自己再做fd-协程对应调度。
基于就绪通知的协程框架
首先需要包装read/write,在发生read的时候检查返回。如果是EAGAIN,那么将当前协程标记为阻塞在对应fd上,然后执行调度函数。
调度函数需要执行epoll(或者从上次的返回结果缓存中取数据,减少内核陷入次数),从中读取一个就绪的fd。如果没有,上下文应当被阻塞到至少有一个fd就绪。
查找这个fd对应的协程上下文对象,并调度过去。
当某个协程被调度到时,他多半应当在调度器返回的路上——也就是read/write读不到数据的时候。因此应当再重试读取,失败的话返回1。
如果读取到数据了,直接返回。
这样,异步的数据读写动作,在我们的想像中就可以变为同步的。而我们知道同步模型会极大降低我们的编程负担。
其实这个模型有个更流行的名字——回调模型。之所以扯上CPS这么高大上的玩意,主要是里面涉及不少有趣的话题。
首先是回调模型的大致过程。在IO调用的时候,同时传入一个函数,作为返回函数。当IO结束时,调用传入的函数来处理下面的流程。这个模型听起来挺简单的。
然后是CPS。用一句话来描述这个模型——他把一切操作都当作了IO,无论干什么,结果要通过回调函数来返回。从这个角度来说,IO回调模型只能被视作CPS的一个特例。
例如,我们需要计算1+2*3,在cps里面就需要这么写:
mul(lambda x: add(pprint.pprint, x, 1), 2, 3)
其中mul和add在python里面如下定义:
add = lambda f, *nums: f(sum(nums))
mul = lambda f, *nums: f(reduce(lambda x,y: x*y, nums))
而且由于python没有TCO,所以这样的写法会产生非常多的frame。
但是要正确理解这个模型,你需要仔细思考一下以下几个问题:
函数的调用过程为什么必须是一个栈?
IO过程在什么时间发生?调用发生时,还是回调时?
回调函数从哪里调用?如果当时利用工具去看上下文的话,调用栈是什么样子的?
函数组件和返回值
不知道你是否思考过为什么函数调用层级(上下文栈)会被表述为一个栈——是否有什么必要性,必须将函数调用的过程定义为一个栈呢?
原因就是返回值和同步顺序。对于大部分函数,我们需要得到函数计算的返回值。而要得到返回值,调用者就必须阻塞直到被调用者返回为止。因此调用者的执行状态就必须被保存,等到被调用者返回后继续——从这点来说,调用其实是最朴素的上下文切换手段。而对于少部分无需返回的函数,我们又往往需要他的顺序外部效应——例如干掉了某个进程,开了一个灯,或者仅仅是在环境变量里面添加了一项内容。而顺序外部效应同样需要等待被调用者返回以表明这个外部效应已经发生。
那么,如果我们不需要返回值也不需要顺序的外部效应呢?例如启动一个背景程序将数据发送到对端,无需保证发送成功的情况下。或者是开始一个数据抓取行为,无需保证抓取的成功。
通常这种需求我们就凑合着用一个同步调用混过去了——反正问题也不严重。但是对于阻塞相当严重的情况而言,很多人还是会考虑到将这个行为做成异步过程。目前最流行的异步调用分解工具就是mq——不仅异步,而且分布。当然,还有一个更简单的非分布方案——开一个coroutine。
而CPS则是另一个方向——函数的返回值可以不返回调用者,而是返回给第三者。
IO 过程在什么时间发生
其实这个问题的核心在于——整个回调模型是基于多路复用的还是基于异步IO的?
原则上两者都可以。你可以监听fd就绪,也可以监听IO完成。当然,即使监听IO完成,也不代表使用了内核态异步接口。很可能只是用epoll封装的而已。
回调函数的上下文环境
这个问题则需要和上面提到的“用户态调度框架”结合起来说。IO回调注册的实质是将回调函数绑定到某个fd上——就如同将coroutine绑定上去那样。只是coroutine允许你顺序的执行,而callback则会切碎函数。当然,大部分实现中,使用callback也有好处——coroutine的最小切换开销也在50ns,而call本身则只有2ns。
状态机模型
状态机模型是一个更难于理解和编程的模型,其本质是每次重入。
想像你是一个周期失忆的病人(就像“一周的朋友”那样)。那么你如何才能完成一项需要跨越周期的工作呢?例如刺绣,种植作物,或者——交一个男朋友。
当然,类比到失忆病人的例子上必须有一点限制。正常的生活技能,还有一些常识性的东西必须不能在周期失忆范围内。例如重新学习认字什么的可没人受的了。
答案就是——做笔记。每次重复失忆后,你需要阅读自己的笔记,观察上次做到哪个步骤,下一个步骤是什么。这需要将一个工作分解为很多步骤,在每个步骤内“重入”直到步骤完成,转移到下一个状态。
同理,在状态机模型解法里,每次执行都需要推演合适的状态,直到工作完成。这个模型已经很少用到了,因为相比回调函数来说,状态机模型更难理解和使用,性能差异也不大。
最后顺带一提,交一个男友的方案和其他几个略有不同,主要靠颜好高冷反差萌,一般人就不要尝试挑战了。。。当然一般人也不会一周失忆一次,毕竟生活不是韩剧也不是日本动漫。。。
30 收藏&&|&&304
你可能感兴趣的文章
33 收藏,4.4k
7 收藏,945
11 收藏,914
看完发现和Python关系不大啊。
看完发现和Python关系不大啊。
写的不错啊,深入浅出.小白学习了.
写的不错啊,深入浅出.小白学习了.
看tornado和gevent源码有帮助吧
看tornado和gevent源码有帮助吧
的确貌似跟python关系没多大的感觉,跟Linux C底层关系比较大的赶脚
的确貌似跟python关系没多大的感觉,跟Linux C底层关系比较大的赶脚
shell当时讲的时候就没怎么听懂...翻译成文章还是诸多疑惑啊...慢慢领悟吧
shell当时讲的时候就没怎么听懂...翻译成文章还是诸多疑惑啊...慢慢领悟吧
去看看tornado的源码吧,看完了你就懂了
去看看tornado的源码吧,看完了你就懂了
关于状态机的,我写了一篇入门的
关于状态机的,我写了一篇入门的/blog/2061790
感觉文章的标题组织优点问题。。
感觉文章的标题组织优点问题。。
感觉文章面向的读者要求比较高,因为讲的东西比较多,很多概念被当做是常识在叙述引用,所以不适合用作入门。
感觉文章面向的读者要求比较高,因为讲的东西比较多,很多概念被当做是常识在叙述引用,所以不适合用作入门。
什么是CPS 模型? 还是 CSP 模型?
什么是CPS 模型? 还是 CSP 模型?
写的真好,把相关的内容全串联起来了,就是觉得有点虎头蛇尾,结尾匆匆忙忙,如果可以对比分析下这几个模型,结合python的几个web框架讲解下就更好了。总之,瑕不掩瑜
写的真好,把相关的内容全串联起来了,就是觉得有点虎头蛇尾,结尾匆匆忙忙,如果可以对比分析下这几个模型,结合python的几个web框架讲解下就更好了。总之,瑕不掩瑜
谢谢建议~
谢谢建议~
语言用到了高阶,都会涉及操作系统
语言用到了高阶,都会涉及操作系统
分享到微博?
我要该,理由是:登录以解锁更多InfoQ新功能
获取更新并接收通知
给您喜爱的内容点赞
关注您喜爱的编辑与同行
966,690 九月 独立访问用户
语言 & 开发
架构 & 设计
文化 & 方法
您目前处于:
Netty案例集锦之多线程篇(续)
Netty案例集锦之多线程篇(续)
50&他的粉丝
日. 估计阅读时间:
:Facebook、Snapchat、Tumblr等背后的核心技术
相关厂商内容
相关赞助商
其实我想了解的是真实生产环境中如何将业务逻辑与Netty网络处理部分很好的作隔离,有没有通用的做法?
重要通知:接下来InfoQ将会选择性地将部分优秀内容首发在微信公众号中,欢迎关注InfoQ微信公众号第一时间阅读精品内容。
1.2. 答疑解惑
Netty的ChannelHandler链由I/O线程执行,如果在I/O线程做复杂的业务逻辑操作,可能会导致I/O线程无法及时进行read()或者write()操作。所以,比较通用的做法如下:
在ChannelHanlder的Codec中进行编解码,由I/O线程做CodeC;
将数据报反序列化成业务Object对象之后,将业务消息封装到Task中,投递到业务线程池中进行处理,I/O线程返回。
不建议的做法:
图1-1 不推荐业务和I/O线程共用同一个线程
推荐做法:
图1-2 建议业务线程和I/O线程隔离
1.3. 问题总结
事实上,并不是说业务ChannelHandler一定不能由NioEventLoop线程执行,如果业务ChannelHandler处理逻辑比较简单,执行时间是受控的,业务I/O线程的负载也不重,在这种应用场景下,业务ChannelHandler可以和I/O操作共享同一个线程。使用这种线程模型会带来两个优势:
开发简单:开发业务ChannelHandler的不需要关注Netty的线程模型,只负责ChannelHandler的业务逻辑开发和编排即可,对开发人员的技能要求会低一些;
性能更高:因为减少了一次线程上下文切换,所以性能会更高。
在实际项目开发中,一些开发人员往往喜欢照葫芦画瓢,并不会分析自己的ChannelHandler更适合在哪种线程模型下处理。如果在ChannelHandler中进行数据库等同步I/O操作,很有可能会导致通信模块被阻塞。所以,选择什么样的线程模型还需要根据项目的具体情况而定,一种比较好的做法是支持策略配置,例如阿里的Dubbo,支持通过配置化的方式让用户选择业务在I/O线程池还是业务线程池中执行,比较灵活。
2. Netty客户端连接问题
2.1. 问题描述
Netty客户端想同时连接多个服务端,使用如下方式,是否可行,我简单测试了下,暂时没有发现问题。代码如下:
EventLoopGroup group = new NioEventLoopGroup();
Bootstrap b = new Bootstrap();
b.group(group)
......代码省略
// Start the client.
ChannelFuture f1 = b.connect(HOST, PORT);
ChannelFuture f2 = b.connect(HOST2, PORT2);
// Wait until the connection is closed.
f1.channel().closeFuture().sync();
f2.channel().closeFuture().sync();
......代码省略
2.2. 答疑解惑
上述代码没有问题,原因是尽管Bootstrap自身不是线程安全的,但是执行Bootstrap的连接操作是串行执行的,而且connect(String inetHost, int inetPort)方法本身是线程安全的,它会创建一个新的NioSocketChannel,并从初始构造的EventLoopGroup中选择一个NioEventLoop线程执行真正的Channel连接操作,与执行Bootstrap的线程无关,所以通过一个Bootstrap连续发起多个连接操作是安全的,它的原理如下:
图2-1 Netty BootStrap工作原理
2.3. 问题总结
注意事项-资源释放问题: 在同一个Bootstrap中连续创建多个客户端连接,需要注意的是EventLoopGroup是共享的,也就是说这些连接共用一个NIO线程组EventLoopGroup,当某个链路发生异常或者关闭时,只需要关闭并释放Channel本身即可,不能同时销毁Channel所使用的NioEventLoop和所在的线程组EventLoopGroup,例如下面的代码片段就是错误的:
ChannelFuture f1 = b.connect(HOST, PORT);
ChannelFuture f2 = b.connect(HOST2, PORT2);
f1.channel().closeFuture().sync();
} finally {
group.shutdownGracefully();
线程安全问题: 需要指出的是Bootstrap不是线程安全的,因此在多个线程中并发操作Bootstrap是一件非常危险的事情,Bootstrap是I/O操作工具类,它自身的逻辑处理非常简单,真正的I/O操作都是由EventLoop线程负责的,所以通常多线程操作同一个Bootstrap实例也是没有意义的,而且容易出错,错误代码如下:
Bootstrap b = new Bootstrap();
//多线程执行初始化、连接等操作
3. 性能数据统计不准确案例
3.1. 问题描述
某生产环境在业务高峰期,偶现服务调用时延突刺问题,时延突然增大的服务没有固定规律,比例虽然很低,但是对客户的体验影响很大,需要尽快定位出问题原因并解决。
3.2. 问题分析
服务调用时延增大,但并不是异常,因此运行日志并不会打印ERROR日志,单靠传统的日志无法进行有效问题定位。利用分布式消息跟踪系统魔镜,进行分布式环境的故障定界。
通过对服务调用时延进行排序和过滤,找出时延增大的服务调用链详细信息,发现业务服务端处理很快,但是消费者统计数据却显示服务端处理非常慢,调用链两端看到的数据不一致,怎么回事?
对调用链的埋点日志进行分析发现,服务端打印的时延是业务服务接口调用的时延,并没有包含:
通信端读取数据报、消息解码和内部消息投递、队列排队的时间
通信端编码业务消息、在通信线程队列排队时间、消息发送到Socket的时间
调用链的工作原理如下:
图3-1 调用链工作原理
将调用链中的消息调度过程详细展开,以服务端读取请求消息为例进行说明,如下图所示:
图3-2 性能统计日志埋点
优化调用链埋点日志,措施如下:
包含客户端和服务端消息编码和解码的耗时
包含请求和应答消息在业务线程池队列中的排队时间;
包含请求和应答消息在通信线程发送队列(数组)中的排队时间
同时,为了方便问题定位,我们需要打印输出Netty的性能统计日志,主要包括:
每条链路接收的总字节数、周期T接收的字节数、消息接收CAPs
每条链路发送的总字节数、周期T发送的字节数、消息发送CAPs
优化之后,上线运行一天之后,我们通过分析比对Netty性能统计日志、调用链日志,发现双方的数据并不一致,Netty性能统计日志统计到的数据与前端门户看到的也不一致,因为怀疑是新增的性能统计功能存在BUG,继续问题定位。
首先对消息发送功能进行CodeReview,发现代码调用完writeAndFlush之后直接对发送的请求消息字节数进行计数,代码如下:
实际上,调用writeAndFlush并不意味着消息已经发送到网络上,它的功能分解如下:
图3-3 writeAndFlush 工作原理图
通过对writeAndFlush方法展开分析,我们发现性能统计代码存在如下几个问题:
业务ChannelHandler的执行时间
ByteBuf在ChannelOutboundBuffer 数组中排队时间
NioEventLoop线程调度时间,它不仅仅只处理消息发送,还负责数据报读取、定时任务执行以及业务定制的其它I/O任务
JDK NIO类库将ByteBuffer写入到网络的时间,包括单条消息的多次写半包
由于性能统计遗漏了上述4个步骤的执行时间,因此统计出来的性能比实际值更高,这会干扰我们的问题定位。
3.3. 问题总结
其它常见性能统计误区汇总:
1. 调用write 方法之后就开始统计发送速率,示例代码如下:
2. 消息编码时进行性能统计,示例代码如下:
编码之后,获取out可读的字节数,然后做累加。编码完成,ByteBuf并没有被加入到发送队列(数组)中,因此在此时做性能统计仍然是不准的。
正确的做法:
调用writeAndFlush方法之后获取ChannelFuture;
新增消息发送ChannelFutureListener,监听消息发送结果,如果消息写入网络Socket成功,则Netty会回调ChannelFutureListener的operationComplete方法;
在消息发送ChannelFutureListener的operationComplete方法中进行性能统计。
示例代码如下:
问题定位出来之后,按照正确的做法对Netty性能统计代码进行了修正,上线之后,结合调用链日志,很快定位出了业务高峰期偶现的部分服务时延毛刺较大问题,优化业务线程池参数配置之后问题得到解决。
3.4. 举一反三
除了消息发送性能统计之外,Netty数据报的读取、消息接收QPS性能统计也非常容易出错,我们第一版性能统计代码消息接收CAPs也不准确,大家知道为什么吗?这个留作问题,供大家自己思考。
4. Netty线程数膨胀案例
4.1. 问题描述
分布式服务框架在进行现网问题定位时,Dump 线程堆栈之后发现Netty的NIO线程竟然有3000多个,大量的NIO线程占用了系统的句柄资源、内存资源、CPU资源等,引发了一些其它问题,需要尽快查明原因并解决线程过多问题。
4.2. 问题分析
在研发环境中模拟现网组网和业务场景,使用jmc工具进行问题定位,
使用飞行记录器对系统运行状况做快照,模拟示例图如下所示:
图4-1 使用jmc工具进行问题定位
获取到黑匣子数据之后,可以对系统的各种重要指标做分析,包括系统数据、内存、GC数据、线程运行状态和数据等,如下图所示:
图4-2 获取系统资源占用详细数据
通过对线程堆栈分析,我们发现Netty的NioEventLoop线程超过了3000个!
图4-3 Netty线程占用超过3000个
对服务框架协议栈的Netty客户端和服务端源码进行CodeReview,发现了问题所在:
客户端每连接1个服务端,就会创建1个新的NioEventLoopGroup,并设置它的线程数为1;
现网有300个+节点,节点之间采用多链路(10个链路),由于业务采用了随机路由,最终每个消费者需要跟其它200多个节点建立长连接,加上自己服务端也需要占用一些NioEventLoop线程,最终客户端单进程线程数膨胀到了3000多个。
业务的伪代码如下:
for(Link linkE : links)
EventLoopGroup group = new NioEventLoopGroup(1);
Bootstrap b = new Bootstrap();
b.group(group)
.channel(NioSocketChannel.class)
.option(ChannelOption.TCP_NODELAY, true)
// 此处省略.....
b.connect(linkE.localAddress, linkE.remoteAddress);
如果客户端对每个链路连接都创建一个新的NioEventLoopGroup,则每个链路就会占用1个独立的NIO线程,最终沦为 1连接:1线程 这种同步阻塞模式线程模型。随着集群组网规模的不断扩大,这会带来严重的线程膨胀问题,最终会发生句柄耗尽无法创建新的线程,或者栈内存溢出。
从另一个角度看,1个NIO线程只处理一条链路也体现不出非阻塞I/O的优势。案例中的错误线程模型如下所示:
图4-4 错误的客户端连接线程使用方式
4.3. 案例总结
无论是服务端监听多个端口,还是客户端连接多个服务端,都需要注意必须要重用NIO线程,否则就会导致线程资源浪费,在大规模组网时还会存在句柄耗尽或者栈溢出等问题。
Netty官方Demo仅仅是个Sample,对用户而言,必须理解Netty的线程模型,否则很容易按照官方Demo的做法,在外层套个For循环连接多个服务端,然后,悲剧就这样发生了。
修正案例中的问题非常简单,原理如下:
图4-5 正确的客户端连接线程模型
5. 作者简介
李林锋,2007年毕业于东北大学,2008年进入华为公司从事高性能通信软件的设计和开发工作,有7年NIO设计和开发经验,精通Netty、Mina等NIO框架和平台中间件,现任华为软件平台架构部架构师,《Netty权威指南》作者。目前从事华为下一代中间件和PaaS平台的架构设计工作。
联系方式:新浪微博 Nettying
微信:Nettying 微信公众号:Netty之家
对于Netty学习中遇到的问题,或者认为有价值的Netty或者NIO相关案例,可以通过上述几种方式联系我。
感谢对本文的策划和审校。
给InfoQ中文站投稿或者参与内容翻译工作,请邮件至。也欢迎大家通过新浪微博(,),微信(微信号:)关注我们,并与我们的编辑和其他读者朋友交流(欢迎加入InfoQ读者交流群(已满),InfoQ读者交流群(#2))。
Author Contacted
语言 & 开发
47 他的粉丝
架构 & 设计
211 他的粉丝
0 他的粉丝
0 他的粉丝
0 他的粉丝
0 他的粉丝
0 他的粉丝
告诉我们您的想法
允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p
当有人回复此评论时请E-mail通知我
Re: 我不太理解“统一投递到后端的业务线程池中进行处理”具体如何操作?
关于统一投递到后端的业务线程池中进行处理
Re: 关于统一投递到后端的业务线程池中进行处理
同步 or 异步
正确的客户端连接线程模型
Re: 正确的客户端连接线程模型
Re: 正确的客户端连接线程模型
Re: 正确的客户端连接线程模型
新线程为啥会阻塞I/O线程呢?
允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p
当有人回复此评论时请E-mail通知我
允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p
当有人回复此评论时请E-mail通知我
赞助商链接
InfoQ每周精要
订阅InfoQ每周精要,加入拥有25万多名资深开发者的庞大技术社区。
架构 & 设计
文化 & 方法
<及所有内容,版权所有 &#169;
C4Media Inc.
服务器由 提供, 我们最信赖的ISP伙伴。
北京创新网媒广告有限公司
京ICP备号-7
找回密码....
InfoQ账号使用的E-mail
关注你最喜爱的话题和作者
快速浏览网站内你所感兴趣话题的精选内容。
内容自由定制
选择想要阅读的主题和喜爱的作者定制自己的新闻源。
设置通知机制以获取内容更新对您而言是否重要
注意:如果要修改您的邮箱,我们将会发送确认邮件到您原来的邮箱。
使用现有的公司名称
修改公司名称为:
公司性质:
使用现有的公司性质
修改公司性质为:
使用现有的公司规模
修改公司规模为:
使用现在的国家
使用现在的省份
Subscribe to our newsletter?
Subscribe to our industry email notices?
我们发现您在使用ad blocker。
我们理解您使用ad blocker的初衷,但为了保证InfoQ能够继续以免费方式为您服务,我们需要您的支持。InfoQ绝不会在未经您许可的情况下将您的数据提供给第三方。我们仅将其用于向读者发送相关广告内容。请您将InfoQ添加至白名单,感谢您的理解与支持。

我要回帖

更多关于 同进程的线程可以共享 的文章

 

随机推荐