苏州自助建站,备案名 网站名,软件开发方案模板,美容产品网站建设多少钱前言 在前后端程序设计开发工作中#xff0c;小伙伴们一定都接触过事件、异步这些概念。出现这些概念的原因之一是#xff0c;我们的代码在执行过程中所涉及的逻辑在不同的场合下执行时间的期望是各不相同的。为了尽量做到充分利用CPU等资源做尽可能多的事#xff0c;免不了… 前言 在前后端程序设计开发工作中小伙伴们一定都接触过事件、异步这些概念。出现这些概念的原因之一是我们的代码在执行过程中所涉及的逻辑在不同的场合下执行时间的期望是各不相同的。为了尽量做到充分利用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都是微任务这里因为有await4要等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输出4then回调进入微任务队列。 4.现在同步代码执行完了回到async函数继续执行输出5。 5.最后运行微任务输出6。 例4 1.首先输出同步代码1然后进入async1方法输出2。 2.因为遇到await所以先进入async2方法后面的7被放入微任务队列。 3.在async2中输出3现在跳出async函数先执行外面的同步代码。 4.输出45。then回调6进入微任务队列。 5.现在宏任务执行完了微任务先入先执行输出7、6。 6.第二轮宏任务输出8。 例5 1.先输出123。3后面的then进入微任务队列。 2.执行外面的同步代码输出45。4后面的then进入微任务队列。 3.接下来执行微任务因为3后面的then先进入所以按序输出67。 4.下面回到async1函数await关键字等到了结果继续往下执行。 5.输出8进行下一轮事件循环也就是宏任务二输出9。 例6 1.函数async1和async2只是定义先不去管他首先输出1。 2.setTimeout作为宏任务进入宏任务队列等待下一轮事件循环。 3.进入async1()函数输出2await下面的代码进入等待状态。 4.进入async2()输出3then回调进入微任务队列。 5.现在执行外面的同步代码输出45then回调进入微任务队列。 6.按序执行微任务输出67。现在回到async1函数。 7.输出data也就是await关键字等到的内容接着输出8。 8.进行下一轮时间循环输出9。 执行结果1 - 2 - 3 - 4 - 5 - 6 - 7 - await的结果 - 8 - 9 例7 1.setTimeout作为宏任务进入宏任务队列等待下一轮事件循环。 2.先执行async1函数输出16进入等待状态现在执行async2。 3.输出2then回调进入微任务队列。 4.接下来执行外面的同步代码输出3then回调进入微任务队列。 5.按序执行微任务输出45。下面回到async1函数。 6.输出了4之后执行了return dataawait拿到了内容。 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如果没开启RPSnapi_gro_receive会直接调用__netif_receive_skb_core看是不是有AF_PACKET类型的socket也就是我们常说的原始套接字如果有的话拷贝一份数据给它。tcpdump抓包就是抓的这里的包。调用协议栈相应的函数将数据包交给协议栈处理。待内存中的所有数据包被处理完成后即poll函数执行完成启用网卡的硬中断这样下次网卡再收到数据的时候就会通知CPU epoll poll函数 这里的poll函数是说注册的回调函数在软中断中进行处理的。比如epoll程序会注册一个“ep_poll_callback” 以go epoll为例 go: accept – pollDesc.Init - poll_runtime_pollOpen – runtime.netpollopen(epoll_create) - epollctl(EPOLL_CTL_ADD) go: netpollblockgopark,让出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_createslab分配器分配内存用来存放struct epitem和struct eppoll_entry。当向系统中添加一个fd时就创建一个epitem结构体这是内核管理epoll的基本数据结构 而每个epoll fdepfd对应的主要数据结构为 struct eventpoll在epoll_create时创建。 其中ep_alloc(struct eventpoll **pep)为pep分配内存并初始化。其中上面注册的操作eventpoll_fops定义如下 这样说来内核中维护了一棵红黑树大致的结构如下clip_image002接着是epoll_ctl函数省略了出错检查等代码 ep_insert的实现如下 这两个函数将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事件发生时的callbackep_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()时内核会将就绪队列中的事件报告给用户。 epoll_wait实现如下 epoll_wait中对ep_poll进行了调用ep_poll实现如下