高并发高性能服务器是如何实现的?
服务器是如何并行处理成千上万个用户请求呢?这里面涉及到哪些技术呢?
多进程
历史上最早出现也是最简单的一种并处处理多个请求的方法就是利用多进程。
比如在 Linux 世界中,我们可以使用 fork、exec 等方法创建多个进程,我们可以在父进程中接收用户的链接请求,然后创建子进程去处理用户请求,就像这样:
这种方法的优点就在于:
编程简单,非常容易理解
由于各个进程的地址空间是相互隔离的,因此一个进程崩溃后并不会影响其它进程
充分利用多核资源
多进程并行处理的优点和明显,但是缺点同样明显:
各个进程地址空间相互隔离,这一优点也会变成缺点,那就是进程间要想通信就会变得比较困难,你需要借助进程间通信(IPC,interprocesscommunications)机制,想一想你现在知道哪些进程间通信机制,然后让你用代码实现呢?显然,进程间通信编程相对复杂,而且性能也是一大问题。
创建进程开销是比线程要大的,频繁的创建销毁进程无疑会加重系统负担。
多线程
由于线程共享进程地址空间,因此线程间通信天然不需要借助任何通信机制,直接读取内存就好了。
线程创建销毁的开销也变小了,要知道线程就像寄居蟹一样,房子(地址空间)都是进程的,自己只是一个租客,因此非常的轻量级,创建销毁的开销也非常小。
我们可以为每个请求创建一个线程,即使一个线程因执行 I/O 操作——比如读取数据库等——被阻塞暂停运行也不会影响到其它线程,就像这样:
但线程就是完美的、包治百病的吗,显然,计算机世界从来没有那么简单。
由于线程共享进程地址空间,这在为线程间通信带来便利的同时也带来了无尽的麻烦。
正是由于线程间共享地址空间,因此一个线程崩溃会导致整个进程崩溃退出,同时线程间通信简直太简单了,简单到线程间通信只需要直接读取内存就可以了,也简单到出现问题也极其容易,死锁、线程间的同步互斥、等等,这些极容易产生 bug,无数程序员宝贵的时间就有相当一部分用来解决多线程带来的无尽问题。
虽然线程也有缺点,但是相比多进程来说,线程更有优势,但想单纯的利用多线程就能解决高并发问题也是不切实际的。
因为虽然线程创建开销相比进程小,但依然也是有开销的,对于动辄数万数十万的链接的高并发服务器来说,创建数万个线程会有性能问题,这包括内存占用、线程间切换,也就是调度的开销。
因此,我们需要进一步思考。
Linux、C/C++技术交流群:【960994558】整理了一些个人觉得比较好的学习书籍、大厂面试题、和技术教学视频资料共享在里面(包括 C/C++,Linux,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK 等等.),有需要的可以自行添加哦!~
Event Loop:事件驱动
到目前为止,我们提到“并行”二字就会想到进程、线程。但是,并行编程只能依赖这两项技术吗,并不是这样的。
还有另一项并行技术广泛应用在 GUI 编程以及服务器编程中,这就是近几年非常流行的事件驱动编程,event-based concurrency。
大家不要觉得这是一项很难懂的技术,实际上事件驱动编程原理上非常简单。
这一技术需要两种原料:
event
处理 event 的函数,这一函数通常被称为 event handler
剩下的就简单了:
你只需要安静的等待 event 到来就好,当 event 到来之后,检查一下 event 的类型,并根据该类型找到对应的 event 处理函数,也就是 event handler,然后直接调用该 event handler 就好了。
以上就是事件驱动编程的全部内容。
从上面的讨论可以看到,我们需要不断的接收 event 然后处理 event,因此我们需要一个循环(用 while 或者 for 循环都可以),这个循环被称为 Event loop。
使用伪代码表示就是这样:
Event loop 中要做的事情其实是非常简单的,只需要等待 event 的带来,然后调用相应的 event 处理函数即可。
注意,这段代码只需要运行在一个线程或者进程中,只需要这一个 event loop 就可以同时处理多个用户请求。
有的同学可以依然不明白为什么这样一个 event loop 可以同时处理多个请求呢?
原因很简单,对于 web 服务器来说,处理一个用户请求时大部分时间其实都用在了 I/O 操作上,像数据库读写、文件读写、网络读写等。当一个请求到来,简单处理之后可能就需要查询数据库等 I/O 操作,我们知道 I/O 是非常慢的,当发起 I/O 后我们大可以不用等待该 I/O 操作完成就可以继续处理接下来的用户请求。
现在你应该明白了吧,虽然上一个用户请求还没有处理完我们其实就可以处理下一个用户请求了,这就是并行,这种并行就可以用事件驱动编程来处理。
这就好比餐厅服务员一样,一个服务员不可能一直等这上一个顾客下单、上菜、吃饭、买单之后才接待下一个顾客,服务员是怎么做的呢?当一个顾客下完单后直接处理下一个顾客,当顾客吃完饭后会自己回来买单结账的。
看到了吧,同样是一个服务员也可以同时处理多个顾客,这个服务员就相当于这里的 Event loop,即使这个 event loop 只运行在一个线程(进程)中也可以同时处理多个用户请求。
相信你已经对事件驱动编程有一个清晰的认知了,那么接下来的问题就是事件驱动、事件驱动,那么这个事件也就是 event 该怎么获取呢?
事件来源:IO 多路复用
IO 多路复用技术正是用来解决这一问题的,通过 IO 多路复用技术,我们一次可以监控多个文件描述,当某个文件(socket)可读或者可写的时候我们就能得到通知啦。
这样 IO 多路复用技术就成了 event loop 的发动机,源源不断的给我们提供各种 event,这样关于 event 来源就解决了。
问题:阻塞式 IO
现在,我们可以使用一个线程(进程)就能基于事件驱动进行并行编程,再也没有了多线程中让人恼火的各种锁、同步互斥、死锁等问题了。 但是,计算机科学中从来没有出现过一种能解决所有问题的技术,现在没有,在可预期的将来也不会有。
程序员最常用的这种 IO 方式被称为阻塞式 IO,也就是说,当我们进行 IO 操作,比如读取文件时,如果文件没有读取完成,那么我们的程序(线程)会被阻塞而暂停执行,这在多线程中不是问题,因为操作系统还可以调度其它线程。
但是在单线程的 event loop 中是有问题的,原因就在于当我们在 event loop 中执行阻塞式 IO 操作时整个线程(event loop)会被暂停运行,这时操作系统将没有其它线程可以调用,因为系统中只有一个 event loop 在处理用户请求,这样当 event loop 线程被阻塞暂停运行时所有用户请求都没有办法被处理,你能想象当服务器在处理其它用户请求读取数据库导致你的请求被暂停吗?
因此,在基于事件驱动编程时有一条注意事项,那就是不允许发起阻塞式 IO。
有的同学可能会问,如果不能发起阻塞式 IO 的话,那么该怎样进行 IO 操作呢?
有阻塞式 IO,就有非阻塞式 IO。
非阻塞 IO
为克服阻塞式 IO 所带来的问题,现代操作系统开始提供一种新的发起 IO 请求的方法,这种方法就是异步 IO。
异步 IO 时,假设调用 aio_read 函数(具体的异步 IO API 请参考具体的操作系统平台),也就是异步读取,当我们调用该函数后可以立即返回,并继续其它事情,虽然此时该文件可能还没有被读取,这样就不会阻塞调用线程了。此外,操作系统还会提供其它方法供调用线程来检测 IO 操作是否完成。
就这样,在操作系统的帮助下 IO 的阻塞调用问题也解决了。
基于事件编程的难点
虽然有异步 IO 来解决 event loop 可能被阻塞的问题,但是基于事件编程依然是困难的。
首先,我们提到,event loop 是运行在一个线程中的,显然一个线程是没有办法充分利用多核资源的,有的同学可能会说那就创建多个 event loop 实例不就可以了,这样就有多个 event loop 线程了,但是这样一来多线程问题又会出现。
异步编程需要结合回调函数,这种编程方式需要把处理逻辑分为两部分,一部分调用方自己处理,另一部分在回调函数中处理,这一编程方式的改变加重了程序员在理解上的负担,基于事件编程的项目后期会很难扩展以及维护。
那么有没有更好的方法呢?
要找到更好的方法,我们需要解决问题的本质,那么这个本质问题是什么呢?
更好的方法
为什么我们要使用异步这种难以理解的方式编程呢?
是因为阻塞式编程虽然容易理解但会导致线程被阻塞而暂停运行。
虽然基于事件编程有这样那样的缺点,但是在当今的高性能高并发服务器上基于事件编程方式依然非常流行,但已经不是纯粹的基于单一线程的事件驱动了,而是 event loop + multi thread + user level thread。
总结
高并发技术从最开始的多进程一路演进到当前的事件驱动,计算机技术就像生物一样也在不断演变进化,但不管怎样,了解历史才能更深刻的理解当下。希望这篇文章能对大家理解高并发服务器有所帮助。
评论