在前后端程序设计开发工作中,小伙伴们一定都接触过事件、异步这些概念。出现这些概念的原因之一是,我们的代码在执行过程中所涉及的逻辑在不同的场合下执行时间的期望是各不相同的。为了尽量做到充分利用CPU等资源做尽可能多的事,免不了通过异步和事件机制的配合来实现系统资源分时复用的效率最大化。相信这个时候后端开发同学肯定会说,我们多线程、协程等并发编程的概念和机制都流行很久了,但大家有没有思考过,服务端各种语言比如golang, JAVA等已经在语言层面帮大家做了相当多的系统底层封装工作。抽象到系统层面,相信大家都知道大名鼎鼎的epoll机制,其核心目标还是实现系统资源分时复用的效率最大化。下面就让我们一起来看看,前后端应用开发场景中异步和事件机制有什么异同吧。
相信前端同学对异步和事件机制会更加敏感,这主要是因为JavaScript的特性导致异步和事件成了语言学习中的必会核心知识点之一。
作为浏览器脚本语言,JavaScript 的主要用途是与用户互动,以及操作 DOM。若以多线程的方式操作这些 DOM,则可能出现操作的冲突。假设有两个线程同时操作一个 DOM 元素,线程 1 要求浏览器删除 DOM,而线程 2 却要求修改 DOM 样式,这时浏览器就无法决定采用哪个线程的操作。当然,我们可以为浏览器引入“锁”的机制来解决这些冲突,但这会大大提高复杂性,所以 JavaScript 从诞生开始就选择了单线程执行。
另外,因为 JavaScript 是单线程的,在某一时刻内只能执行特定的一个任务,并且会阻塞其它任务执行。那么对于类似 I/O 等耗时的任务,就没必要等待他们执行完后才继续后面的操作。在这些任务完成前,JavaScript 完全可以往下执行其他操作,当这些耗时的任务完成后则以回调的方式执行相应处理。这些就是 JavaScript 与生俱来的特性:异步与回调。
当然对于不可避免的耗时操作(如:繁重的运算,多重循环),HTML5 提出了Web Worker,它会在当前 JavaScript 的执行主线程中利用 Worker 类新开辟一个额外的线程来加载和运行特定的 JavaScript 文件,这个新的线程和 JavaScript 的主线程之间并不会互相影响和阻塞执行,而且在 Web Worker 中提供了这个新线程和 JavaScript 主线程之间数据交换的接口:postMessage 和 onMessage 事件。但在 HTML5 Web Worker 中是不能操作 DOM 的,任何需要操作 DOM 的任务都需要委托给 JavaScript 主线程来执行,所以虽然引入 HTML5 Web Worker,但仍然没有改线 JavaScript 单线程的本质。
单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。js引擎执行异步代码而不用等待,是因有为有 消息队列和事件循环。
消息队列:消息队列是一个先进先出的队列,它里面存放着各种消息。
事件循环:事件循环是指主线程重复从消息队列中取消息、执行的过程。
实际上,主线程只会做一件事情,就是从消息队列里面取消息、执行消息,再取消息、再执行。当消息队列为空时,就会等待直到消息队列变成非空。而且主线程只有在将当前的消息执行完成后,才会去取下一个消息。这种机制就叫做事件循环机制,取一个消息并执行的过程叫做一次循环。整个机制如下图所示:
这里有几个概念:事件循环、调用栈(执行站)、微任务、宏任务、事件队列机制如下图所示:
其中上图中的web api和对应的queue在实际应用场景对应两个:微任务和微任务队列、宏任务和宏任务队列,微任务和宏任务的定义包含:
不同类型的任务会进入对应的Event Queue,比如setTimeout和setInterval会进入相同(宏任务)的Event Queue。而Promise和process.nextTick会进入相同(微任务)的Event Queue。
1.「宏任务」、「微任务」都是队列,一段代码执行时,会先执行宏任务中的同步代码。
2.进行第一轮事件循环的时候会把全部的js脚本当成一个宏任务来运行。
3.如果执行中遇到setTimeout之类宏任务,那么就把这个setTimeout内部的函数推入「宏任务的队列」中,下一轮宏任务执行时调用。
4.如果执行中遇到 promise.then() 之类的微任务,就会推入到「当前宏任务的微任务队列」中,在本轮宏任务的同步代码都执行完成后,依次执行所有的微任务。
5.第一轮事件循环中当执行完全部的同步脚本以及微任务队列中的事件,这一轮事件循环就结束了,开始第二轮事件循环。
6.第二轮事件循环同理先执行同步脚本,遇到其他宏任务代码块继续追加到「宏任务的队列」中,遇到微任务,就会推入到「当前宏任务的微任务队列」中,在本轮宏任务的同步代码执行都完成后,依次执行当前所有的微任务。
7.开始第三轮,循环往复...
下面举例子来说明
例1
1、new Promise 的函数体是同步脚本所以先执行的是1、2。
2.3和4都是微任务,这里因为有await,4要等Promise.then()之后才会执行。console.log('4')已经被放在await语法糖生成的Promise.then里了,而await的等待必须要等后面Promise.then之后才会结束。
例2
1.6是宏任务在下一轮事件循环执行
2.先同步输出1,然后调用了async1(),输出2。
3.await async2() 会先运行async2(),5进入等待状态。
4.输出3,这个时候先执行async函数外的同步代码输出4。
5.最后await拿到等待的结果继续往下执行输出5。
6.进入第二轮事件循环输出6。
例3
1.首先输出1,然后进入async1()函数,输出2。
2.await后面虽然是一个直接量,但是还是会先执行async函数外的同步代码。
3.输出3,进入Promise输出4,then回调进入微任务队列。
4.现在同步代码执行完了,回到async函数继续执行输出5。
5.最后运行微任务输出6。
例4
1.首先输出同步代码1,然后进入async1方法输出2。
2.因为遇到await所以先进入async2方法,后面的7被放入微任务队列。
3.在async2中输出3,现在跳出async函数先执行外面的同步代码。
4.输出4,5。then回调6进入微任务队列。
5.现在宏任务执行完了,微任务先入先执行输出7、6。
6.第二轮宏任务输出8。
例5
1.先输出1,2,3。3后面的then进入微任务队列。
2.执行外面的同步代码,输出4,5。4后面的then进入微任务队列。
3.接下来执行微任务,因为3后面的then先进入,所以按序输出6,7。
4.下面回到async1函数,await关键字等到了结果继续往下执行。
5.输出8,进行下一轮事件循环也就是宏任务二,输出9。
例6
1.函数async1和async2只是定义先不去管他,首先输出1。
2.setTimeout作为宏任务进入宏任务队列等待下一轮事件循环。
3.进入async1()函数输出2,await下面的代码进入等待状态。
4.进入async2()输出3,then回调进入微任务队列。
5.现在执行外面的同步代码,输出4,5,then回调进入微任务队列。
6.按序执行微任务,输出6,7。现在回到async1函数。
7.输出data,也就是await关键字等到的内容,接着输出8。
8.进行下一轮时间循环输出9。
执行结果:1 - 2 - 3 - 4 - 5 - 6 - 7 - await的结果 - 8 - 9
例7
1.setTimeout作为宏任务进入宏任务队列等待下一轮事件循环。
2.先执行async1函数,输出1,6进入等待状态,现在执行async2。
3.输出2,then回调进入微任务队列。
4.接下来执行外面的同步代码输出3,then回调进入微任务队列。
5.按序执行微任务,输出4,5。下面回到async1函数。
6.输出了4之后执行了return data,await拿到了内容。
7.继续执行输出6,执行了后面的 return data 才触发了async1()的then回调输出7以及data。
8.进行第二轮事件循环输出8。
执行结果:1 - 2 - 3 -4 - 5 - 6 - 7 - async2的结果 - 8
后端的情况会根据语言的不同有细微差异,但核心原理和机制是一致的,这里我们以golang为例进行分析。
在golang中,异步调用的实现和其他编程语言有所不同,golang采用goroutine(协程)的方式实现异步调用。goroutine是一种轻量级的线程,可以在程序中创建多个协程,每个协程都是独立的,并且可以并发执行。
在实际应用中,异步调用常用于以下几个场景:
1.网络请求
在网络通信中,由于网络状况的不确定性,请求的响应时间可能会非常长,如果采用同步调用的方式,就会造成程序长时间阻塞,影响用户体验。因此,我们可以采用异步调用的方式,在请求之后不必等待响应,而是继续执行其他任务,等到响应到来之后再处理。
2.文件操作
对于一些文件操作,可能需要进行大量的I/O操作,如读取文件内容、写入文件等。这些I/O操作比较耗时,如果采用同步调用的方式,可能会造成程序阻塞并且效率低下。因此,我们可以采用异步调用的方式,在文件操作需要花费大量时间时,使用goroutine执行任务,不会影响主线程的正常运行。
3.定时任务
在一些定时任务中,可能需要执行一些比较耗时的操作。如果采用同步调用的方式,可能会影响程序的时间精度和稳定性。因此,我们可以使用异步调用的方式,在主线程执行定时任务的同时,开启goroutine执行具体的操作任务,不会影响程序的精度和稳定性。
在golang中,我们可以使用goroutine和channel来实现异步调用的功能。
1.使用goroutine实现异步调用
在golang中,开启一个goroutine非常简单,只需要在函数前面加上go关键字即可,例如:
上述代码就是在新的goroutine中执行一个任务。我们来看一个完整的示例代码:
通过上述代码,我们可以看到,程序开启了一个goroutine执行任务,同时主线程也在执行另一个任务。在程序运行过程中,主线程和goroutine可以同时运行,相互不影响。
2.使用channel实现异步调用
在golang中,channel是goroutine之间通信的一种方式,我们可以使用channel来实现异步调用。我们可以创建一个带有缓冲区的channel,然后在goroutine中执行任务,并将结果通过channel传递给主线程,如下所示:
在上述代码中,我们创建了一个带有缓冲区的channel,并在goroutine中执行一个任务,任务的结果通过channel传递给主线程。主线程通过循环读取channel中的数据,当channel关闭时,通过ok变量来判断循环是否结束,从而确保程序能够正常退出。
在服务端中涉及到事件应用主要是发生I/O请求时,而这其中网络I/O在golange中占很重要的比重。当设备上有数据到达的时候,会给 CPU 的相关引脚上触发⼀个电压变化,以通知 CPU 来处理数据。也可以把这个叫 硬中断。
但是我们知道,cpu运行速度很快,但是网络读取数据会很慢,这时候就会长期占用cpu,导致cpu无法处理其他事件,比如,鼠标移动。那么在linux中是怎么解决掉这个问题的呢?linux内核将中断处理拆分开,拆分为了2个部分,一个是上面提到的 硬中断,另外就是 软中断。
第一部分接收到cpu电压变化,产生硬中断,然后只做最简单的处理,然后异步的交给硬件去接收信息到缓冲区。这个时候,cpu就已经可以接收其他中断信息过来了。
第二部分就是软中断部分,软中断是怎么做的呢?其实就是对内存的二进制位进行变更,类似于我们平常写业务常用的到的status字段一样,比如网络Io中,当缓冲区接收数据完毕,会将当前状态改为完成。举个例子,epoll读取某个io时间读取完数据时,并不会直接进入就绪态,而是等下次循环遍历判断状态,才会将这个fd塞入就绪列表(当然,这个时间很短,不过相对于cpu来说,这个时间就很长了)。
2.4 以后的内核版本采⽤的下半部实现⽅式是软中断,由 ksoftirqd 内核线程全权处理。和硬中断不同的是,硬中断是通过给 CPU 物理引脚施加电压变化,⽽软中断是通过给内存中的⼀个变量的⼆进制值以通知软中断处理程序。
这也就是为什么知道2.6才有epoll(正式引入)使用的原因,2.4以前内核都不支持这种方式。
总体的数据流转图如下:
一个数据从到达网卡,要经历以下步骤才会完成一次数据接收:
数据包从外面的网络进入物理网卡。如果目的地址不是该网卡,且该网卡没有开启混杂模式,该包会被网卡丢弃。
网卡将数据包通过DMA的方式写入到指定的内存地址,该地址由网卡驱动分配并初始化。注:老的网卡可能不支持DMA,不过新的网卡一般都支持。
网卡通过硬件中断(IRQ)通知CPU,告诉它有数据来了
CPU根据中断表,调用已经注册的中断函数,这个中断函数会调到驱动程序(NIC Driver)中相应的函数
驱动先禁用网卡的中断,表示驱动程序已经知道内存中有数据了,告诉网卡下次再收到数据包直接写内存就可以了,不要再通知CPU了,这样可以提高效率,避免CPU不停的被中断。
启动软中断。这步结束后,硬件中断处理函数就结束返回了。由于硬中断处理程序执行的过程中不能被中断,所以如果它执行时间过长,会导致CPU没法响应其它硬件的中断,于是内核引入软中断,这样可以将硬中断处理函数中耗时的部分移到软中断处理函数里面来慢慢处理。
内核中的ksoftirqd进程专门负责软中断的处理,当它收到软中断后,就会调用相应软中断所对应的处理函数,对于上面第6步中是网卡驱动模块抛出的软中断,ksoftirqd会调用网络模块的net_rx_action函数
net_rx_action调用网卡驱动里的poll函数来一个一个的处理数据包
在pool函数中,驱动会一个接一个的读取网卡写到内存中的数据包,内存中数据包的格式只有驱动知道
驱动程序将内存中的数据包转换成内核网络模块能识别的skb格式,然后调用napi_gro_receive函数
napi_gro_receive会处理GRO相关的内容,也就是将可以合并的数据包进行合并,这样就只需要调用一次协议栈。然后判断是否开启了RPS,如果开启了,将会调用enqueue_to_backlog
在enqueue_to_backlog函数中,会将数据包放入CPU的softnet_data结构体的input_pkt_queue中,然后返回,如果input_pkt_queue满了的话,该数据包将会被丢弃,queue的大小可以通过net.core.netdev_max_backlog来配置
CPU会接着在自己的软中断上下文中处理自己input_pkt_queue里的网络数据(调用__netif_receive_skb_core)
如果没开启RPS,napi_gro_receive会直接调用__netif_receive_skb_core
看是不是有AF_PACKET类型的socket(也就是我们常说的原始套接字),如果有的话,拷贝一份数据给它。tcpdump抓包就是抓的这里的包。
调用协议栈相应的函数,将数据包交给协议栈处理。
待内存中的所有数据包被处理完成后(即poll函数执行完成),启用网卡的硬中断,这样下次网卡再收到数据的时候就会通知CPU
以go epoll为例:
go: accept –> pollDesc.Init -> poll_runtime_pollOpen –> runtime.netpollopen(epoll_create) -> epollctl(EPOLL_CTL_ADD)
go: netpollblock(gopark),让出cpu->调度回来,netpoll(0)将协程写入就绪态->其他操作…
epoll thread: epoll_create(ep_ptable_queue_proc,注册软中断到ksoftirqd,将方法ep_poll_callback注册到)->epoll_add->epoll_wait(ep_poll让出cpu)
core: 网卡接收到数据->dma+硬中断->软中断->系统调度到ksoftirqd,处理ep_poll_callback(这里要注意,新的连接进入到程序,不是通过callback,而是走accept)->获取到之前注册的fd句柄->copy网卡数据到句柄->根据事件类型,对fd进行操作(就绪列表)
部分代码
go: accept
epoll源码
epoll用kmem_cache_create(slab分配器)分配内存用来存放struct epitem和struct eppoll_entry。当向系统中添加一个fd时,就创建一个epitem结构体,这是内核管理epoll的基本数据结构:
而每个epoll fd(epfd)对应的主要数据结构为:
struct eventpoll在epoll_create时创建。
其中,ep_alloc(struct eventpoll **pep)为pep分配内存,并初始化。
其中,上面注册的操作eventpoll_fops定义如下:
这样说来,内核中维护了一棵红黑树,大致的结构如下:
clip_image002
接着是epoll_ctl函数(省略了出错检查等代码):
这两个函数将ep_ptable_queue_proc注册到epq.pt中的qproc。
执行f_op->poll(tfile, &epq.pt)时,XXX_poll(tfile, &epq.pt)函数会执行poll_wait(),poll_wait()会调用epq.pt.qproc函数,即为ep_ptable_queue_proc。
ep_ptable_queue_proc函数如下:
ep_ptable_queue_proc(ep_poll_callback)其中struct eppoll_entry定义如下:
在ep_ptable_queue_proc函数中,引入了另外一个非常重要的数据结构eppoll_entry。
eppoll_entry要完成epitem和epitem事件发生时的callback(ep_poll_callback)函数之间的关联。首先将eppoll_entry的whead指向fd的设备等待队列(同select中的wait_address),然后初始化eppoll_entry的base变量指向epitem,最后通过add_wait_queue将epoll_entry挂载到fd的设备等待队列上。
完成这个动作后,epoll_entry已经被挂载到fd的设备等待队列。
由于ep_ptable_queue_proc函数设置了等待队列的ep_poll_callback回调函数。所以在设备硬件数据到来时,硬件中断处理函数中会唤醒该等待队列上等待的进程时,会调用唤醒函数ep_poll_callback。
所以ep_poll_callback函数主要的功能是将被监视文件的等待事件就绪时,将文件对应的epitem实例添加到就绪队列中,当用户调用epoll_wait()时,内核会将就绪队列中的事件报告给用户。