本文主要记录服务器的 IO 模型的类型(从多路复用,异步 IO 讲到 Proactor Reactor 模型),包括 Real World nginx 和 apache ,kafka 等分析,配备自洽的所有知识点方便自己复习。读者应该具备一些 linux 系统知识。


先把 APUE 第八章进程控制复习一遍吧

Linux 进程的控制

启动与复制

  • 首先是初始进程 swapper pid==0 的进程,swapper 本来是用来换页的(以前内存不够几个程序用),就如在 xv6 里面的那样,init 进程(系统启动后的第一个进程)做的就是 wait 到子进程(shell)退出然后就退出而已。scheduler 的部分是另外的,由于他的调度过程十分复杂,所以有很多设置和初始化的内容,但是大体都是在 timer interrupt 之后处理调度的问题。
  • 以前的 swapper 进程是当 CPU idle 的时候运行的(现在应该没有了),这个应该是第一个内核线程(因为 init 是用户进程运行的),The swapper process, as was, used to perform process swap operations. It used to swap entire processes — including all of the kernel-space data structures for the process — out to disc and swap them back in again。然后的内核进程还有 page daemon (负责换页)等。
  • 现在这个应该是 idle 进程,stackoverflow 说了:两个原因,编程原因是不用特判无进程可调度的 case,历史原因是老 CPU 不支持什么都不做(节电模式),起码要运行一大堆 NOP,今天可以用 HALT 来停机。
  • fork 后文件描述符是全部共享的,相当于调用了 dup(refcount 也会增加)。

After a successful return, the old and new file descriptors may
       be used interchangeably.  Since the two file descriptors refer to
       the same open file description, they share file offset and file
       status flags;

From <dup(2) - Linux manual page>

  • 对于文件锁的必要性,知道文件描述符 fd 对应的文件结构肯定是同一个,所以系统调用共享 offset 几乎是必然。(fd 只不过是指向内核里的打开文件表,然后每个文件表又指向 vfs 的节点表最后到 inode)。但是子进程不会继承文件锁,因此还是有一定的线程安全性。
  • fork 和 exec 分开一个好处是可以换条件再 exec 比如修改重定向,修改用户组,解绑终端。
  • 另一个 vfork 保证是要执行一个新的程序,尽管他说不推荐使用,但是实际还是有挺多区别的。第一个是 vfork 不会赋值地址空间(页表),而是直接使用父亲的,所以需要保证不要修改。然后第二点是 vfork 马上保证子进程在运行直到 exec 或者 exit 被调用回到 kernel。fork 的问题是他会新建一个 mm_struct 复制全部的页表,exec 则会创建全新的地址空间,覆写整个 mm_struct,即两步内容。(对于这个 CoW 操作到底浪费多少性能存疑)。由于共享,所以如果没有 exec,然后调用了 exit 会引发所有的文件以及标准 io 被关闭,unix 的解决方案是引入一个 _exit 不冲刷标准 io 文件符。
  • 进程可以通过 nice 函数修改自己的友好度(对 OS 而言),inc 意味着调度 priority 下降。

login 原理

  • 登录的原理,系统开机的时候 init 进程会读取 ttys 文件(一个配置各种终端和设备驱动绑定的配置文件),根据指定 fork tty数量个 init,然后他们将会运行 gettty 程序(这个程序会死循环 login 提示直到登录成功,这就是为什么服务器远程过去一个 tty 输错密码之后会一直轮询,只要设备没有关闭,就死循环的),生成包含代环境变量的环境(环境变量其实就是字符串 kv map),设备将会被 open 系统调用通过驱动程序打开。getty 会执行 login 程序,login 程序会配置环境变量,修改终端的各种权限(终端的权限本来是继承 init which 是空的,没有任何权限),然后调用用户登录 shell (每个用户用什么 shell 登录会写在 passwd 里面)。
  • 如果这个登录的 shell 死了之后,顶部的 init 将会知道(因为他不断在 waitpid),他重启这个 tty 的 gettty 程序等待 login(又一轮轮回)。(或者用户关闭设备后,getty 会收到一个 write 错误(或者 print 错误),init 将停止这个 tty 设备的访问,直到下次死循环 open 这个设备又成功,循环往复)。
  • 网络登录原理,由于网络登录需要持续等待一个请求进来,所以需要一个服务器。默认绑定的地址是 INADDR_ANY ,即通配。然后绑定一个伪终端设备,pseudo terminal,这个设备会完成一层 wrap(也是 vfs 的一部分)。由于网络用户不直接使用 shell 开机 ,所以对于所有公共用户的开机自启程序 /etc/rc(which 使用脚本定义) 需要独立于登录程序先启动,包括 inetd 服务器,所以开机会首先运行一个 shell 脚本。
  • 而 shell 的作用只是执行脚本,结束后 shell 会死,脚本启动的程序(forked 来的)就会被 init 收养。
  • 然后 inetd 超级服务器守护进程只是超级的(负责路由各种不同的请求给其他 daemon 进程),对于子连接协议比如 telnet 还要用 telnetd (inetd 会完成和 telnetd 的通信)来监控网络请求和负责和伪终端通信。伪终端是通过虚拟设备实现的(比如覆盖标准io的fd),实际还是通过驱动程序。

守护进程脱离控制终端

  • 控制终端的概念和进程组,session 密切相关,这个如果读者不了解,下一个小节会介绍。
  • 守护进程如果由 shell 或者其他应用 fork+exec 而来,必须自己管理组,脱离活动终端。
  • crontab 守护进程的作用(不是英语,是时间表的意思),这个守护进程会定制执行程序(程序全部作为守护进程运行,即没有活动终端,可以重定向 stdio 写文件)。
  • 由于守护进程没有终端,unix 设计了 syslog 来打 log(补充一下 window 下用 OutputDebugString 然后用 DebugView 软件捕获)。(但是这个也是一个守护进程,涉及 IPC)。syslog 可配置文件,终端或者其他各种东西(比如再转发一次 IPC)。syslog 的 well-known 端口号是 UDP 514。他会循环 select log 套接字,服务套接字,内核错误套接字。
  • syslog 函数的参数,level 和 facility 的概念, level 由 EMERG,ALERT,CRIT(临界),ERR,WARNING,NOTICE,INFO,DEBUG。facility 主要是进程类型。通过一个 priority 和 | 运算符实现单个参数表示两种值了属于。
  • 这些不同的 priority 会根据 conf 路由到不同的地方,配置是基于 priority 的。openlog 可以添加选项比如进程通信失败的时候直接 stdio,控制延迟和 stderr 同步输出,每条信息登记 PID 等。然而 linux 里面(ubuntu)可能不用 syslog 了,搞了一个 rsyslog,多线程 tcp,ssl,tls,支持过滤和自定义格式,支持 module。

守护进程 daemon process

  • session id,thread group id 这些都是用头头(leader)进程的 pid 标识的。
  • session 的原理是区分不同的用户的,每个 session 会有多个进程组,进程组是用来区分各种后台的(以及控制终端),组内管理信号,组间信号独立。
  • 一个 session 有一个拥有控制终端的 group(前台进程组),以及后台进程组。终端关闭的时候会发送 sigup 信号。
  • Ctrl+C 对应的是 SIGINT,Ctrl+Z 对应的是 SIGTSTP,Ctrl+\ 对应SIGQUIT, 关机会发送 SIGTERM 信号,最后会 SIGKILL。
  • 作业控制 job control 是 shell 支持的 shell 中创建进程组的方法。(如果读者做过 CSAPP ICS 15-213 的 shell lab 应该对此印象深刻)。

The  daemon()  function  is for programs wishing to detach themselves from the controlling terminal and run in the background as system daemons.

  • 在 UNIX 的各种限制下,daemon 的实现原理是,fork 之后做掉父进程,这样 shell 恢复正常。然后修改 session(脱离原来的 session 使各方面都独立),setsid 根据 APUE的说法,会脱离控制终端(脱离控制终端的意思就是脱离 stdio 以及脱离上面一系列的终端产生 SIGNAL),切断控制终端,成为新的 leader,但是不能已经是 leader。为了不接收 tc 的 sighup(很容易理解,设置 sighup 信号就是为了告知大家控制终端被关了的实现方式->即 leader 死了。然而这个是必要条件不是充要条件) :
  • SIGHUP会在以下3种情况下被发送给相应的进程:

  1、终端关闭时,该信号被发送到session首进程以及作为job提交的进程(即用 & 符号提交的进程);

  2、session首进程退出时,该信号被发送到该session中的前台进程组中的每一个进程;

  3、若父进程退出导致进程组成为孤儿进程组,且该进程组中有进程处于停止状态(收到SIGSTOP或SIGTSTP信号),该信号会被发送到该进程组中的每一个进程。

  • 在新的 session 下再 fork 保证不会关联控制终端(这是因为如果一个会话 leader 打开一个新的终端,并且open 没有用 NOCTTY,就会绑定一个终端给整个 session)。由于守护进程有可能会打开终端(比如作为远程连接的 daemon 时)。还有一些其他设置包括重定向io,关闭文件,打 log,chdir。
  • 由于 linux 下 daemon 函数都为我们做好了,这里不再深究细节了(不过已经太多细节了)。

自启动

  • 自启动其实是操作系统的基本功能,主要是启动一系列的 daemon 进程。
  • Unix 下的自启动脚本会在 /etc/rc 中配置,这在 linux 里是一系列文件:rcx.d(下面是在 Ubuntu 系统下运行 whereis rc 的运行结果)

rc: /etc/rc5.d /etc/rc4.d /etc/rc6.d /etc/rc0.d /etc/rc3.d /etc/rc1.d /etc/rc2.d

  • 为了减轻大量 daemon 监听文件描述符引发的大量进程调度来进行 socket 轮询的性能问题,提出使用 inetd 支持各种 daemon 服务。实现原理是通过配置文件指定各种服务的实现,他只负责 listen,这样就不用一大堆 daemon 来 listen 了。(然后 exec 就行了,通常需要提供参数)。由于 fork exec 分开的设计,可以在中间 hook socket 递交。

25 章了讲 SIGIO 的用法的。现在快速过了他。

各种 IO 模型以及信号驱动模型简析

不同 IO 辨析(这个是理解 proactor 的关键)

  • SIGIO 的原理是不通过 select 来进行 polling,而是通过注册信号,让内核在 IO 启动的时候 interrupt 。
  • 和 asynchronous IO 的区别是,信号驱动是内核告知上层可以操作, whereas asynchronous IO 是说注册 IO 事件实现 kernel IO + 上层的 computing 的 parallelism。
  • 非阻塞只是说如果当前资源不可用才会回到上层,不然还是会 u -> k -> u 的顺序执行。
  • 直接看这个图(用 windows 画图画的,,)好了(我认为这个应该很清楚了,如果有错误欢迎指出!),红色是上层计算工作,橙色是 IO 操作,黑色是用户态进程上下文,灰色是 kernel 上下文。(异步 IO 可能画得不好,kernel 可能会等一段时间才变橙色,或者 kernel 线可能是另一个 background process 的实现也行)

  • 通过 fcntl 的 SETOWN 设置 host(意思是 SIGIO 和 SIGURG 通知的是哪个进程组,方便设置另一个线来做这个 handler),然后通过 SETFL 设置 ASYNC 选项(叫 async 是历史原因),或者通过 ioctl 设置 FIOASYNC。
  • 然后另外提供一个等价 NONBLOCK 的选项是 ioctl 的 FIONBIO。

UDP 事件:

  • datagram 到达或者出现async错误。
  • asynchronous 错误就是之前说的,recvfrom 永远诸塞就算有 ICMP 不可达信号。这是因为 sendto 没有什么 ack 机制,就算不可达,内核也不会告知 upper layer,导致本来不应该调用 recv 的,这是 asynchronuous 错误。
  • sendto 只保证了数据进入 buffer,而不是确认没有收到 ICMP since UDP 没有 ACK 机制
  • 因为 recvfrom 无法同时返回地址信息只能返回 errno,所以规定只有 connected udp 才返回这些错误,比如这里就通过信号(还是要 connected udp)。
  • 不要在 TCP 上使用 SIGIO 因为他的情况太多了。

同步问题

  • 标准的做法是生产者消费者问题,让 handler 做生产者(recvfrom),然后 main loop 不断地消费(writeto),为了保证消费成功以及维护队列 meta 变量(只有 handler 和 main loop 都用到的变量才需要 coordinate),需要 sigprocmask 短暂禁止 SIGIO。这一点知道就行了。
  • 回顾一下之前看过的 sigsuspend ,sigsuspend 和 sleeplock 的那个很像,醒来之后会恢复之前的状态,比如本来阻塞 SIGIO 然后要他睡在 SIGIO 上,收到 SIGIO 醒来之后 SIGIO 还是被阻塞的。然后根据下面的manual 说的(我又忘记了),signal handler 里面不会再接受到 masked 的信号,那些信号会排队(或者重复降为一次pending 等待递交)。

Any signals specified in act->sa_mask when registering the
             handler with
sigprocmask(2) are added to the thread's
             signal mask.  The signal being delivered is also added to
             the signal mask, unless
SA_NODEFER was specified when
             registering the handler.  These signals are thus blocked
             while the handler executes.

From <signal(7) - Linux manual page>

  • 信号的排队问题和递交次数问题,之前说过信号的不可靠的 FIFO 的,之前说过的确有排队重复信号,但是 SIGIO 不是,解决方案是循环调用 NONBLOCK 的 recvfrom。

为什么 pthread 线程快

  • UNIX 线程是 lightweight process,创建比 process 快,就算 fork 是 Cow 的(3-5倍甚至几十倍)。
  • 我不知道怎么做思想实验,只能知道 glibc 里面 pthread_create 就是分配了一些栈,然后 clone 整个进程。而 fork 是 clone 再复制 4 级页表。
  • 我感觉主要区别在页表上,fork 的页表必须是独立的,然后全部设置 CoW bit,也就是必须复制 4 层页表的每一层,会有一大堆复制操作(存疑?比如几mb情况最多就4个页表,os详细内容都没什么印象了,没法定量算了。。。)而 pthread 改一个页表指针就行了属于,直接全共享。然后对于切换的情况,因为一个是复制的页表,本质还是不一样的(比如 cow bit),tlb 必须 flush,而 pthread 不用。感觉这样就够有说服力了。
  • 额,搜了一些博客才看到狗老师这篇文章Linux fork隐藏的开销,写得很好,这里摘录一个页表开销,对于稀疏地址空间,页表反而更多了,笑死。比如一个低地址一个高地址,至少 1(root) + 2(二级) + 2(三级) + 2(四级) 个目录,说起来当时学的时候没体会到这一点。。而且现在都是 heap 下面,stack 上面往下,中间还有各种 mmap,还是有很多稀疏的情况的。
  • 线程的好处是随便阻塞,反正内核会调度,而且很简单就切一下 context,pagetable 指针不带变的。这里摘一些 APUE 的原文吧。

每个线程都有自己独立的signal mask,但所有线程共享进程的signal action。这意味着,你可以在线程中调用pthread_sigmask(不是sigmask)来决定本线程阻塞哪些信号。但你不能调用sigaction来指定单个线程的信号处理方式。如果在某个线程中调用了sigaction处理某个信号,那么这个进程中的未阻塞这个信号的线程在收到这个信号都会按同一种方式处理这个信号。另外,注意子线程的mask是会从主线程继承而来的。

然后下面是一些总结。


C/S 架构

时间关系对 raw socket DL 层的没时间看了,虽然我的确很感兴趣那些可以用来抓包和做 arp 欺诈等功能。书本还讲了 ping 和 traceroute 怎么写,这下知道面试问这两个原理哪来的了。。。

首先总结我们前面做了些什么工作,(这个说的是服务器还是客户端呢?好像都有)

简单服务器和客户端

  • 基本 TCP 服务器,停止等待,无法利用 CPU
  • select 阻塞 IO 迭代服务器,问题在于标准 io 和 socket buffer 处理速度不一致,在得到标准 I 写的时候或者标准io缓冲满时还是会阻塞。
  • select 非阻塞 IO 迭代服务器,应用层生产者消费者环形缓冲区,如果 block 就挤压到应用缓冲什么都不做下次再(incremental)。
  • 多线程阻塞服务器,内核调度(性能差别在于内核调度是 timer interrupt + 两次 trap ,反正多进程flush tlb 的肯定不行,然后有内核 pcb(因为1:1线程模型)内存开销)。
  • 一种做法是多核多线程 io 复用,这样应该是理论最佳性能的,但是想达到最大吞吐还要精密的调度方案。

Real World 方案

  • apache 有三种模型,preforking 模型,一般 1024 给进程稳定低并发。worker 模型,多个主控进程配备很多被控 thread,动态更新线程数量,线程崩溃进程也崩,中间妥协。event 模型是在 worker 的基础上用一些线程来 epoll 管理 keep-alive 长连接(http需要维护一个长连接),对于真正的请求转发给服务线程。
  • nginx 是多进程模型,每个进程单线程复用。master 进程管理 worker 进程,通过信号来通信。所以同样要处理 accept 的 mutex 问题,然后 worker 内部由于是 epoll,所以全部用异步非阻塞IO,worker 配置为核数,避免 worker 间的 context switch(额,最理想就直接做个内核态服务器不好吗哈哈)。
  • libevent,Reactor 反应堆模型,IO 事件select返回的时候通过 server dispatch 给 callback function。额,这为什么起了个这么 fancy 的名字。

preforking 进程池方案

  • 父进程只是负责创建 listenfd 和 fork 一堆孩子
  • main loop 调用 pause 等待一个信号(关闭服务器),如果信号来了就 kill 孩子(sigterm)。然后 wait。
  • 孩子不断地 accept 然后处理。
  • 理解多进程 accept 原理,listenfd 是完全 dup 出来的,然后题目指向的是同一个 opened file 结构,
  • thundering herd 性能问题,这里还要由内核来保证互斥锁的问题。一种优化是让 kernel 不 thundering herd 而是只 wakeup sleep on chan 的第一个。
  • 不要让多进程阻塞在 select 上,这个和 kernel 的 socket 实现有关,如果 socket 的 wake chan 里面只记录了一个 pid,会引发冲突(就频繁睡在 select 上 which 更新这个 pid 导致原来的 select 睡者没办被唤醒)。
  • 对于 accept 没有实现互斥锁(用户态 accept)就需要自定义互斥锁了。(apache 1.1 跨平台支持就要做这两种实现)
  • 不想实现锁的方法让父进程(master)单进程 accept,就没有并发锁问题了,然而现在两个问题了他有,一个是 IPC,一个是调度策略。但是这样涉及怎么把 fd 传过去,理论上只有 fork 才能继承描述符结构体,现在通过 socketpair 来实现(Unix domain socket,啊这,我跳过这些章节了,没事 apue 还有)。
  • udsocket 的原理是 sendmsg 和 recvmsg,然后msghdr 里面会指明一些属性。里面有一个是 void* 的 msgcontrol 字段,可以让他指向一个 cmsghdr 意思是 control msg,然后这个 hdr 里面的 cmsg_type 的 SCM_RIGHTS 代表文件描述符。
  • 本来我以为 loopback 优化很好了

最开始 127.0.0.1也是走 tcp协议栈的,很多冗余的东西,而 AF_UNIX 更像跨进程管道,因此会快很多。但是后面 Linux持续优化,给 127.0.0.1加了很多 fast path。使得本机内部的tcp性能和 AF_UNIX差不多,也和管道一个数量级了,所以今天 Linux下,保证代码的简单性,没有非要用 AF_UNIX的必要。

链接:https://www.zhihu.com/question/29910140/answer/46640164

  • 然而 UDS 真的有用啊,这种涉及特殊的 PCB 数据传送,,,,为什么不直接提供一个抽象出来呢。。。

消息队列

MQ 特性

  • 消息队列是链表,具备 header:length,priority
  • mq 的标识符是 mqd,ms descriptor。mqclose 和 close 的做法是一样的(linux manual)。
  • mq 队列是内核持续性,即在内核生命周期存在。mqopen 之后会产生3个文件 .MQDxxx, .MQLxxx, .MQPxxx。unp 也不知道他们是什么,只能猜测(v22e5.3)是 data + lock + permission 三个文件。
  • 每个mq 具备一个 attr 结构体,这些是设置 mq 的属性的,一个是 flags,block 什么的和file的一样,一个是 maxmsg 链表长度,最大消息长度,当前size。比较简单的 adt。

信号(纯 posix)

  • mqnotify 函数,参数中 sigevent 是一个 posix 新的信号结构体管理实时信号(之前看到的 SIGUSRX,其中 X 是数字)的回调函数的。。。
  • 一个函数负责注册和注销(sigevent 用 nullptr 区分)。
  • 如果空的消息队列被添加了信号就会激活。
  • 然后 mqreceive 是不可重入的,所以不要在 handler 里面调用他。(记住 IO 只用 read write 就行了!)signal-safety(7) - Linux manual page (man7.org),还有回顾那个设置一个 bit(atomic),然后在main 的控制流里面轮询检测也是一种方案(但是需要非常 sophisticate 的编程,比如要配合 sigprocmask + sigsuspend + pselect 使用),5.6.3 也详细讲了这个方案的实施。
  • 整理一下这些信号引发的系统性问题,除了更久之前的因为在慢系统调用之前信号到达所以要用 pselect 或者非阻塞,前面讲非阻塞 IO 和 高级 UDP 的时候的问题是 recvfrom 之前 alarm 到达(他这里 alarm 的handler 什么都不做,只是为了打断慢系统调用而已),这里则是 mqnotify 重新注册太慢了,而信号只支持空队列添时通知,注册的时候已经来了一个了,所以失败了。(注意在处理数据的时候需要短暂 block 信号)
  • 解决方案是 mqrecv 的时候用非阻塞并且循环调用,额,这个问题又是新的了,所以循环读 mq 就行了,因为非阻塞最后会返回 egain 即 -1 bytes (return val 是 msg bytes)这个时候就能下次 mqnotify 了。(这些例程可都太重要了,但是总感觉网络编程是在 linux / unix 这个框框下搞各种注意事项和 workaround,有点 c++ 的感觉了 😓)。
  • 结果他跟我说可以思想升级,直接用 sigwait 而不用设置什么信号 bit 和 suspend 前判断 bit。sigwait 不用 handler,直接等信号发生就返回。(sigwait 调用前先 sigprocmask block 先)。这个太重要了,代码必须贴一份(首先 mark 一下路径 pxmsg/mqnotifysig4.c)

区分一下这两种方案:

mq 不支持 select 的 workaroud

  • mqd 不能被 select 和 poll!epoll 也不支持。。需要魔改一下。
  • 看完后笑死了,搞一个中间商 pipe 徒增功耗。这是什么ios 锁屏歌词刷新壁纸的方案。。。
  • 实现方法是因为 write 是 async signal safe 的,所以创建一个 private pipe,让 mqnotify 的 signal handler 写管道,这样就变相通知 poll / select 了,于是可以返回。。。。我真的持续体会各种 workaround 的难受了属于。
  • 等于搞了一大堆 context switch 和 vfs 中间路由层层转包了。

mq 异步信号通知还支持创建一个线程来 callback

  • 只需要把之前那个 event 结构体的参数设置成 SIGEV_THREAD 就行了,然后回调函数会被调用。
  • 不过 callback function 记得要用 pthread_exit 来退出。

实现问题

  • 这里用 mmap 和 fix length msg 来实现了一个互斥的 posix mq。500行 C 代码
  • 涉及互斥锁的东西。
  • linux 系统提供 eventfd 的文件描述符让他支持 select 和 poll。

real world - kafka

  •  真正的中间件级别的消息队列涉及更专业的 recv 和 send。主要就是生产消费模型。
  • 由于没有内核的控制了,所以基本都要自己造轮子。(我们之前是可以用 kill 发信号实现 mqnotify 的)。
  • 所以 kafka 是怎么保证高效率的信号 notify 机制的呢?这个其实通过思想实验就是一系列的回调!类似 RAII 的思想把所有东西均摊开来,而不是用一个大循环去轮询。这个回调和控制反转的 Observer 模式是一样的。只要订阅者希望订阅某些消息,如果消息来了(做到 enqueue 里面)就能触发回调函数了!
  • 消息队列的用途比如写日志,秒杀事务backlog。
  • 但是 kafka 这个东西是集群系统的,分区+容错(没有实时,消息队列的确是异步的,又复习 CAP 了),很难继续看下去(除非开一个源码分析来学)。
  • 消息队列也是分布式系统,所以自然会需要 RPC (RPC 这个东西我才刚刚接触,个人理解就是一个用户层协议,至于他到底要解决什么问题,我在这个文章里面记录了一部分),自然会涉及分布式的各种问题。

因为 Epoll 原理线索另一篇博客讲了(没有 epoll 的上下文信息下面的内容应该看不懂)

Reactor 和 Proactor

这里因为只能看游双那本,这部分记得有点像 specification 的笔记了,但是没办法,这个东西实在是我觉得就是一个花哨名字而已。结合上面 CS 架构小节的内容,对于 preforking 方案已经很了解了。

这里 Reactor 就是简单的 preforking 方案(或者 threading 也行)然后让主线程 epoll listen descriptor 和已经连接的 descriptor,如果有连接到达或者 fd 有活动,就引发一个调度(回想消息队列和 epoll wait,这里做思想实验是我们只需要唤醒一个 worker 来处理就行了,如果是已经连接的,那么唤醒那个特定的 worker),并且把 fd 交给线程进行业务逻辑。使用消息队列调度工作线程。其实就是说让 epoll 来管理所有的阻塞事件,listend 会阻塞,交给 epoll wait!cond 会有阻塞,交给 epoll_wait ! (包括可读了,读完之后要阻塞写怎么办?交给 epoll_wait !)。其实就是通过避免 OS 的 epoll 唤醒链太复杂,所以都把阻塞唤醒放在 master 线程(进程)上,从而得到高性能。

而 Proactor 就是说让 worker 只进行业务逻辑,所有 IO 都通过主线程调用异步 IO 或者直接调用异步 IO 实现,主线程(进程)只需要 epoll listen descriptor(因为已连接的 d 委托给了异步 IO,自然就不需要阻塞了)。我认为为了更好地理解 Proactor,应该先看下面的同步模拟 Proactor(IO 全部在 master 上)。其实就是说让 kernel 的 asynchronous IO 来管理所有的阻塞事件,要读东西可能阻塞怎么办?让内核读完了再递交上来! 要写东西可能阻塞怎么办?让内核写完了再通知上来!(然而对于 listen d 我们必须要 accept 他才能进行 r/w ,所以这个必须要让 master 线程来阻塞等待!)

下面再详细分析一下:

Reactor 模式

反应堆模型,master 负责 IO 复用,epoll 套接字,如果有事件来了就加入消息队列通知 worker。worker 会注册写事件等待,然后同样事件到达了就加入消息队列等待工作线程响应。下面的流程图特别的清楚。

  • master epoll 全部 d ,包括 listend 和 连接好的 d。epoll wait
  • 既然说了主线程不会处理事件,所以对于 listen d,也 dispatch 给一个专门的 connection manager (as a worker)做就行了。
  • 然后 socket 从 epoll wait 回来之后,加入一个队列里(生产者消费者模型),而且应该要做一个 EPOLLONESHOT 的选项。
  •  worker 阻塞写的操作也会委托给主进程来 epoll wait,而 worker 直接 sleep(就如下面的图片那样)。
  • 然后还要通知 wake up 一个工作线程

proactor 模式 前摄式,这个单词不懂什么意思

  • epoll 监听还是一样的,不同的地方在于异步 IO,这里所有线程(进程)都不需要阻塞
  • 主线程只需要监听 listend,因为 cond 的读阻塞会发生在内核,此时 worker 可以睡觉,因为内核完成之后会通过 sigevent 注册好的来唤醒一个 worker 善后(比如关闭连接什么的)。
  • 所有 IO 让主线程(和内核)负责,完全解耦 worker
  • 异步 IO 模型,aio read,aio write 再让主线程和 内核解耦(信号的关键字是 sigevent,其实之前讲过了,可能印象不太深)。
  • aread 成功之后,dispatch 一个 worker 来进行业务操作,然后 worker 又 aio write 进行写。

  • 实现同步 IO 模拟 proactor,主要方法就是让主线程完成 IO

  • 原理是主线程来完成本来交给 aio 的工作。(用户态调度 io 了属于)

性能分析

  • 对于 compute bound 的,上下文切换浪费生命。
  • 对于 IO bound,切换上下文浪费的时间就很少了。
  • HTTP 解析是一个有限状态机。

再总结两种服务器模型!

  • 我认为系统跟 UNP 和 APUE 学下来的话,对这两个模式的认识只是一个花哨名字而已。他的本质就是很简单的两个利用信号或者消息做的不同的IO复用方案。上面我说可能写得过于 specification 风格了,其实不难理解的在有前面这么多内容的基础之上。
  • 首先是这两个模型都是 preforking 模型(或者 prethreading)。
  • 对于 Reactor 而言,其实就是说让 epoll 来管理所有的阻塞事件,listend 会阻塞,交给 epoll wait!cond 会有阻塞,交给 epoll_wait ! (包括可读了,读完之后要阻塞写怎么办?交给 epoll_wait !)。其实就是通过避免 OS 的 epoll 唤醒链太复杂,所以都把阻塞唤醒放在 master 线程(进程)上,从而得到高性能。
  • 对于 Proactor 而言,其实就是说让 kernel 的 asynchronous IO 来管理所有的阻塞事件,要读东西可能阻塞怎么办?让内核读完了再递交上来! 要写东西可能阻塞怎么办?让内核写完了再通知上来!(然而对于 listen d 我们必须要 accept 他才能进行 r/w ,所以这个必须要让 master 线程来阻塞等待!)
Logo

Kafka开源项目指南提供详尽教程,助开发者掌握其架构、配置和使用,实现高效数据流管理和实时处理。它高性能、可扩展,适合日志收集和实时数据处理,通过持久化保障数据安全,是企业大数据生态系统的核心。

更多推荐