看这篇文章的时候,你有没有想过服务器是怎么把这篇文章发给你的?简单明了,不就是一个用户请求吗??服务器根据请求从数据库中检索这篇文章,然后通过网络发回。这很复杂。服务器如何并行处理数千个用户请求?这其中涉及到哪些技术?本文将为您解答这个问题。最早和最简单的并行处理多个请求的方法是使用多进程。比如在Linux世界中,我们可以使用fork、exec等系统调用来创建多个进程,我们可以在父进程中接收用户连接请求,然后创建子进程来处理用户请求,像这样:这种方法的原因是:编程简单易懂由于各个进程的地址空间是相互隔离的,一个进程崩溃不会影响其他进程。充分利用多核资源,多进程并行处理,优点很明显,但缺点也很明显:各个进程地址空间相互隔离,这个优点也会变成缺点,就是它进程之间的通信将变得更加困难。你需要使用进程间通信(IPC,interprocesscommunications)机制。想想你现在知道的进程间通信机制,然后让你用代码去实现它?显然,进程间通信编程比较复杂,性能也是个大问题。我们知道创建进程的成本要大于线程。频繁的创建和销毁进程无疑会增加系统的负担。.幸运的是,除了进程,我们还有线程。多线程创建一个进程不是很昂贵吗?进程之间通信不难吗?这些不是线程的问题。什么?如果你还不了解线程,请看这篇文章《看完这篇还不懂线程与线程池你来打我》,里面详细解释了线程这个概念是怎么来的。由于线程共享进程地址空间,线程之间的通信自然不需要任何通信机制,直接读取内存即可。线程创建和销毁的开销也减少了。你必须知道线程就像寄居蟹。房子(地址空间)都是进程,你只是一个房客。因此,它非常轻量,创建和销毁的开销也很小。我们可以为每个请求创建一个线程,即使一个线程因为执行I/O操作——比如读取数据库等——而被阻塞挂起,也不会影响其他线程,像这样:但是线程是完美的,包治百病?显然,计算机世界从未如此简单。由于线程共享进程地址空间,这在给线程间通信带来方便的同时,也带来了无穷无尽的麻烦。正是因为地址空间是线程间共享的,所以一个线程崩溃就会导致整个进程崩溃退出。同时,线程之间的通信简直太简单了。简单到线程之间的通信只需要直接读取内存就可以了,简单到没有任何问题。极易,死锁,线程间的同步互斥等等,这些极易产生bug,无数程序员的宝贵时间有相当一部分被用来解决多线程带来的层出不穷的问题。虽然线程也有缺点,线程比多进程有更多的优势,但是简单的使用多线程来解决高并发问题是不切实际的。因为线程的创建开销虽然比进程小,但是还是有开销的。对于几万、几十万连接的高并发服务器,创建几万个线程会带来性能问题,包括内存占用,线程间切换,也就是调度的开销。因此,我们需要进一步思考。EventLoop:事件驱动到目前为止,提到“并行”这个词,我们就会想到进程和线程。然而,并行编程是否只能依赖这两种技术呢?不是这种情况。在GUI编程和服务器编程中还有一种广泛使用的并行技术,就是近几年非常流行的事件驱动编程,event-basedconcurrency。不要认为这是一项很难理解的技术。其实事件驱动编程从原理上来说是非常简单的。这个技术需要两个原材料:event一个处理事件的函数,这个函数通常称为事件处理程序,剩下的很简单:你只是静静地等待事件的到来,当事件到来时,检查事件的类型,并根据类型找到对应的事件处理函数,即事件处理器,然后直接调用事件处理器。就是这样!以上就是事件驱动编程的全部内容,是不是很简单!从上面的讨论可以看出,我们需要不断的接收事件,然后处理事件,所以需要一个循环(while循环或者for循环都可以)。这个循环称为事件循环。伪代码是这样表达的:while(true){event=getEvent();handler(event);}Event循环中要做的事情其实很简单,只需要等待事件带过来,然后调用相应的事件处理函数。注意这段代码只需要在一个线程或进程中运行,同时处理多个用户请求只需要一个事件循环。可能有些同学还不明白,为什么这样的事件循环可以同时处理多个请求呢?原因很简单。对于Web服务器来说,处理用户请求的大部分时间其实都花在了I/O操作上,比如数据库读写、文件读写、网络读写等,当一个请求来的时候,可能需要查询数据库等简单处理后的I/O操作。我们知道I/O很慢。I/O发起后,我们可以继续处理,无需等待I/O操作完成。后续用户请求。现在你应该明白了,虽然上一个用户请求还没有处理完,但是我们实际上可以处理下一个用户请求,这也是并行的,而这种并行可以用事件驱动编程来处理。这就像餐厅里的服务员。一个服务员不可能等到上一个顾客点单、上菜、吃饭、结账,才接待下一个顾客。服务员是怎么做到的?当客户下订单时,他直接处理下一个订单。顾客,等顾客吃完了,自己回来结账。看,同一个服务员可以同时接待多个顾客。这个waiter相当于这里的事件循环。即使事件循环只在一个线程(进程)中运行,它也可以同时处理多个用户请求。相信大家已经对事件驱动编程有了清晰的认识,那么接下来的问题就是事件驱动,事件驱动,那么如何获取这个事件,也就是event?事件来源:《终于明白了,一文彻底理解I/O多路复用》中的IO多路复用在这篇文章中,我们知道在Linux/Unix的世界里万物皆文件,我们的程序都是通过文件描述符来进行I/O操作的,当然套接字也不例外,那么如何我们处理多个文件描述符呢?IO多路复用技术就是用来解决这个问题的。通过IO多路复用技术,我们可以同时监控多个文件描述。当文件(套接字)可读或可写时,我们会收到通知。这样,IO多路复用技术就成为了事件循环的原材料供应商,源源不断地为我们提供各种事件,从而解决了事件来源的问题。当然,关于IO多路复用技术的详细解释,请参考《终于明白了,一文彻底理解I/O多路复用》。至此,使用事件驱动实现并发编程的问题都解决了吗?事件源的问题已经解决。获取到事件后,调用相应的handler,就好像大功告成了。想想还有什么问题吗?问题:阻塞IO现在,我们可以用一个线程(进程)来进行事件驱动的并行编程,再也没有多线程中烦人的锁和同步交互了。排除、死锁等问题。然而,计算机科学从来没有解决所有问题的技术,现在没有,在可预见的未来也不会有。上面的方法有没有问题?不要忘记我们的事件循环在一个线程(进程)中运行。这样虽然解决了多线程的问题,但是如果在处理一个事件的时候需要IO操作怎么办?在《读取文件时,程序经历了什么》一文中,我们讲解了最常用的文件读取是如何在底层实现的。程序员最常用的IO方式叫做阻塞IO。也就是说,当我们进行IO操作时,比如读取文件时,如果文件还没有被读取,我们的程序(线程)就会被阻塞,暂停执行。这在多线程中不是问题,因为操作系统也可以调度其他线程。但是单线程事件循环有问题。原因是当我们在事件循环中进行阻塞IO操作时,整个线程(事件循环)都会被挂起。这时候操作系统就没有其他线程可以调度了。由于系统中只有一个事件循环处理用户请求,当事件循环线程被阻塞挂起时,无法处理所有用户请求。你能想象当服务器正在处理其他用户读取数据库的请求时,你的请求被挂起了吗??所以在事件驱动编程中有一个注意事项,就是不允许阻塞IO。有的同学可能会问,如果阻塞IO不能发起,那么如何进行IO操作呢?有阻塞IO,也有非阻塞IO。非阻塞IO为了克服阻塞IO带来的问题,现代操作系统开始提供一种新的发起IO请求的方法。这个方法就是异步IO。相应的,阻塞IO就是同步IO。关于同步和异步这两个概念可以参考《从小白到高手,你需要理解同步与异步》。对于异步IO,假设调用了aio_read函数(具体异步IOAPI请参考具体操作系统平台),即异步读取。当我们调用这个函数时,我们可以立即返回并继续其他的事情,虽然此时读取的文件可能还没有被读取,这样调用线程就不会被阻塞。此外,操作系统还会提供其他方法供调用线程检测IO操作是否完成。这样,IO的阻塞调用问题也借助操作系统解决了。基于事件编程的难点虽然有异步IO解决了事件循环可能阻塞的问题,但是基于事件的编程仍然是难点。首先,我们提到事件循环在一个线程中运行。显然,一个线程无法充分利用多核资源。可能有同学会说,创建多个事件循环实例就可以了,这样就有多个事件循环线程了,但是这样一来多线程的问题又会出现。还有一点就是编程。我们在《从小白到高手,你需要理解同步与异步》一文中提到,异步编程需要结合回调函数(回调函数请参考《程序员应如何彻底理解回调函数》)。这种编程方式需要将处理逻辑分为两部分。一部分调用者自己处理,另一部分在回调函数中处理。这种编程方式的改变,增加了程序员在理解上的负担。基于事件的编程项目后期将难以扩展和维护。那么有没有更好的办法呢?要想找到更好的方法,就要解决问题的本质,那么本质问题是什么?更好的方法为什么我们要以一种难以理解的方式使用异步编程?是的因为阻塞式编程很容易理解,它会导致线程被阻塞而停止运行。那么聪明的你肯定会问,有没有办法结合同步IO的简单理解,又不会因为同步调用导致线程阻塞呢?答案是肯定的,这就是usermodethread,userlevelthread,也就是大名鼎鼎的coroutine,值得单独写一篇关于coroutines的文章来说明,下一篇。虽然基于事件的编程有各种各样的缺点,但是基于事件的编程在当今的高性能高并发服务器上仍然很流行,只不过它不再是单纯的基于单线程的事件驱动,而是事件循环+多线程+用户级线程。关于这个组合,也值得拿出一篇文章来解释一下,我们会在后续的文章中详细讨论。总结一下高并发技术的演进,从最初的多进程到现在的事件驱动,计算机技术就像有生命的东西一样在不断地进化,但无论如何,只有了解历史才能更深刻地了解现在。希望本文能帮助大家了解高并发服务器。本文转载自微信公众号《码农的荒岛求生》,可通过以下二维码关注。转载本文请联系码农荒岛求生公众号。
