当前位置: 首页 > 科技观察

协程有什么用?六大I-O模式告诉你!

时间:2023-03-17 10:14:29 科技观察

大家好,我是小风哥,今天来聊聊协程的作用。假设磁盘上有10个文件,需要读取内存,如何用代码实现呢?在继续之前,你自己想想这个问题,看看你能想出多少种方法。优缺点都有什么。想通了吗(还在看吗),想通了,我们继续往下看。最简单的方式——连载这大概是大部分同学能想到的最简单的方式了,那就是一篇一篇的读,读一篇再读下一篇。代码表示是这样的:forfileinfiles:result=file.read()process(result)不是很简单,我们假设每个文件需要1分钟读取,那么10个文件总共需要10分钟读取结束。这种方法有什么问题?其实这种方法只有一个问题,那就是速度慢。除此之外,其他都好:代码简单,易懂易维护,这段代码谁都可以维护(至于程序员的核心竞争力在哪里)怎么解决速度慢的问题?可能有的同学想到了,为什么要一一阅读呢?并行阅读不能加快速度吗?稍微好一点的方法,并行那么,如何并行读取文件呢?显然,地球人都知道线程是用来并行的。我们可以同时开启10个线程,每个线程读取一个文件。代码实现是这样的:defread_and_process(file):result=file.read()process(result)defmain():files=[fileA,fileB,fileC...]forfileinfiles:create_thread(read_and_process,file).run()#等待这些线程完成如何,是不是很简单。那么这种方法有什么问题呢?在10个线程打开的问题大小下,没有问题。现在我们把问题变得更难了。假设有10000个文件,怎么办?有同学可能会疑惑10个文件和10000个文件有什么区别吗?直接创建10000个线程读取不行吗?其实这里的问题其实是创建多线程有没有问题。我们知道,虽然线程号称“轻量级进程”,虽然是轻量级的,但是当数量足够可观时,还是会存在性能问题。这里的问题主要有以下几个方面:创建线程需要消耗系统资源,比如内存等(想想为什么?)调度开销,尤其是线程数量多,比较忙的时候(也想想为什么?)创建多线程并不一定能加速I/O(如果此时设备的处理能力已经饱和)。既然线程有这样那样的问题,有没有更好的办法呢?答案是肯定的,并行编程并不一定要依赖线程这种技术。这里的答案是基于事件驱动的编程技术。事件驱动+异步是的,即使在单线程中,使用事件驱动+异步也可以实现IO并行处理。Node.js就是一个非常典型的例子。为什么单线程可以并行?这是基于两个事实:与CPU的处理速度相比,IO是很慢的。IO不需要太多的计算资源。那么,为什么我们发起一个IO操作后还要等待呢?那么IO执行完成呢?在IO执行完成之前的那段时间处理其他IO不好吗?这就是为什么单线程也可以并行处理多个IO的本质。回到我们的例子,如何用事件驱动+异步改造上面的程序呢?其实很简单。首先,我们需要创建一个事件循环,很简单:event_loop=EventLoop()然后,我们需要在事件循环中添加原材料,即需要监听的事件,像这样:defadd_to_event_loop(event_loop,file):file.asyn_read()#异步读取文件event_loop.add(file)注意当执行到file.asyn_read这行代码时,会立即返回,不会阻塞线程。当这行代码返回时,文件可能还没有真正开始读取,也就是所谓的异步。file.asyn_read这行代码的真正目的只是发起IO,而不是等待IO执行完成。之后我们将IO放到事件循环中进行监听,也就是这行代码event_loop.add(file)的作用。一切准备就绪,接下来就可以等待事件的到来了:,那么我们就可以得到完成的文件,然后我们就可以进行处理了。整个代码如下:defadd_to_event_loop(event_loop,file):file.asyn_read()#异步读取文件event_loop.add(file)defmain():files=[fileA,fileB,fileC...]event_loop=EventLoop()forfileinfiles:add_to_event_loop(event_loop,file)whileevent_loop:file=event_loop.wait_one_IO_ready()process(file.result)多线程VS单线程+事件循环接下来我们看看程序执行的效果.在多线程的情况下,假设有10个文件,每个文件读取耗时1秒,那么很简单,并行读取10个文件耗时1秒。那么单线程+事件循环呢?再看一下事件循环+异步版本的代码:defadd_to_event_loop(event_loop,file):file.asyn_read()#异步读取文件event_loop.add(file)defmain():files=[fileA,fileB,fileC...]event_loop=EventLoop()forfileinfiles:add_to_event_loop(event_loop,file)whileevent_loop:file=event_loop.wait_one_IO_ready()process(file.result)foradd_to_event_loop,由于文件是异步读取的,所以这个函数可以瞬间执行。真正耗时的函数其实是事件循环的等待函数,即:file=event_loop.wait_one_IO_ready()我们知道一个文件的读取时间是1秒,所以函数要等到1s后才返回,但是,但是,接下来就是重点了。但是虽然函数wait_one_IO_ready会等待1s,但是不要忘了我们用这两行代码同时发起了10个IO操作请求。forfileinfiles:add_to_event_loop(event_loop,file)所以在等待event_loop.wait_one_IO_ready的1s期间,剩下的9个IO也都完成了,也就是说event_loop.wait_one_IO_ready函数只会在第一个循环等待1s,但是接下来的9个循环会直接返回,因为剩下的9个IO也都完成了。所以整个程序的执行时间也是1秒。我们只用一个线程就能达到10个线程的效果,是不是很神奇。这就是事件循环+异步的威力所在。一个好听的名字:Reactorsmode从本质上讲,我们上面给出的事件循环的简单代码片段在本质上与生物做同样的事情:给予刺激并做出反应。这里我们给出事件,然后处理事件。这本质上就是所谓的Reactors模式。现在你应该明白所谓的Reactors模式是怎么回事了。所谓看似复杂的异步框架,其核心只是这里给出的代码片段,但是这些框架可以支持更复杂的多阶段任务处理和各种类型的IO。而我们这里给出的代码片段只能处理这种文件读取的IO。如果我们需要处理各种类型的IO,上面的代码片段会不会有什么问题呢?问题是上面的代码片段不会这么简单,不同的类型会有不同的处理方式,所以上面的处理方式需要判断IO的类型,然后有针对性的处理,这会使代码越来越复杂,难以维护。幸运的是,我们也有一个应对策略,那就是回调。我们可以将IO完成后的处理任务封装成一个回调函数,然后在事件循环中向IO注册。就像这样:defIO_type_1(event_loop,io):io.start()defcallback(result):process_IO_type_1(result)event_loop.add((io,callback))这样event_loop就可以把IO和关联的回调处理函数一起检索,直接调用回调函数即可。whileevent_loop:io,callback=event_loop.wait_one_IO_ready()callback(io.result)可以看到,所以event_loop内部极其简单,even_loop根本不关心如何处理IO结果,这就是注册的回调应该关心的事情,event_loop需要做的就是获取事件和对应的处理函数回调,然后调用回调函数。现在我们可以用单线程并发编程,用callback来抽象IO处理,让代码更容易维护。想一想,有什么问题吗?回调函数的问题虽然回调函数让事件循环更加简洁,但是还是有其他的问题,我们仔细看看回调函数:defstart_IO_type_1(event_loop,io):io.start()defcallback(result):process_IO_type_1(result)event_loop.add((io,callback))从上面你能看出代码中有什么问题吗?上面代码中,一个IO处理过程分为两部分:发起IOIO处理,第二部分放在回调函数中。这样的异步处理自然不好理解,和我们的不一样。熟悉的发起IO、等待IO完成、处理IO结果的同步模块是有很大区别的。这里举的例子很简单,大家可能没有注意,但是当处理任务很复杂的时候,回调函数中可能会嵌套回调函数,也就是回调地狱。这样的代码维护会让你想知道为什么被称为勤劳的码农。哪里有问题?让我们仔细看看问题出在哪里。同步编程方式很简单,但是同步方式发起IO时,线程会被阻塞,所以我们要创建多个线程,但是线程太多又会出现性能问题。这样,为了在发起IO后不阻塞当前线程,我们不得不使用异步编程+事件循环。在这种模式下,异步发起IO不会阻塞调用线程。我们可以采用单线程加异步编程的方式来实现多线程的效果,但是在这种模式下,处理一个IO的过程不得不拆分成两部分,这样的代码对于程序员来说是违反直觉的,因而难以维护。那么自然而然,有没有办法简单的理解同步编程和非阻塞异步编程呢?最后!最后,我到达了协程。使用协程,我可以以同步形式进行异步编程。这是什么意思?我们之所以使用异步编程,是为了在发起IO后不阻塞当前线程,而是使用协程。程序员可以决定什么时候挂起当前协程,这样当前线程就不会被阻塞。协程最好的地方就是它可以在挂起后暂时存储执行状态,恢复后在挂起点继续运行,这样我们就不用再像回调那样把一个IO进程拆分成两部分了。因此,我们可以发起异步IO,让当前线程不会被阻塞,同时在发起异步IO后,将当前协程挂起,等IO完成后,再恢复协程的运行,这样我们就可以实现异步以同步方式编程。接下来我们使用协程修改回调版本的IO处理方式:defstart_IO_type_1(io):io.start()#IO异步请求yield#挂起当前协程process_IO_type_1(result)#处理完返回结果,我们将协程放入事件循环中进行监听:defadd_to_event_loop(io,event_loop):coroutine=start_IO_type_1(io)next(coroutine)event_loop.add(coroutine)最后,当IO完成后,事件循环检索对应的coroutineandResumeitsoperation:whileevent_loop:coroutine=event_loop.wait_one_IO_ready()next(coroutine)现在你应该看到上面的代码中没有回调,也没有把处理IO的过程分成两部分。整体代码都是以同步的方式写的,最好的是还能实现异步的效果。其实你会看到采用协程之后,我们还是需要一个基于事件编程的事件循环,因为协程在本质上并没有改变IO的异步处理性质。只要IO是异步处理的,我们就必须依靠事件循环来监听IO何时完成,但是我们使用协程来消除对回调的依赖,整体的编程方式还是采用大家最熟悉也最容易理解的同步方式程序员的方法。总结看似简单的IO其实一点都不简单。为了高效的进行IO操作,我们采用的技术是这样演变的:单线程串行+阻塞IO(同步)多线程并行+阻塞IO(并行)单线程+非阻塞IO(异步)+事件循环单线程+非阻塞IO(异步)+事件循环+回调Reactor模式(更好的单线程+非阻塞IO+事件循环+回调)单线程+非阻塞IO(异步)+事件循环+协程最后我们异步编程的效率和同步编程的简单理解都是通过使用协程技术获得的,协程技术也是当今高性能服务器常用的技术组合。