为了账号安全,请及时绑定邮箱和手机立即绑定

大名鼎鼎的 IO 多路复用

标签:
PHP

什么是操作系统

基础概念

操作系统是一种非常特殊的软件。这种软件对上服务着我们用户的程序(Application),对下管理着我们的硬件

image.png

你不需要自己写程序去访问键盘、硬盘等硬件。比如当你在 windows 上打开一个记事本,通过键盘上的各种按键,windows 帮你把字符写入到文件内。同时在你的 windows 电脑上,同时运行着 QQ、微信、邮件等不同的应用,每个应用会占用磁盘、内存、端口以及被分配 CPU 时间片。

用户态和内核态

现代操作系统,有一个“保护”的概念,也就是会把一些特别危险的操作、特别核心的操作只能我自己来运行。用户想来访问硬盘、网络,想访问一些其他敏感的硬件,不好意思只能通过我的内核来访问,你不可以直接去访问

image.png

image.png

Inter CPU 指令级别分别是 Ring0~Ring3,Ring0 级别最高,Ring3 级别最低。 在 Linux 系统中,Ring0 作为内核态,Ring3 作为用户态。

假如 Linux 进程的有 4GB 地址空间,3G-4G 部分大家是共享的,是内核态的地址空间,这里存放在整个内核的代码和所有的内核模块,以及内核所维护的数据。用户运行一个程序,该程序所创建的进程开始是运行在用户态的,如果要执行文件操作,网络数据发送等操作,必须通过 writesend 等系统调用,这些系统调用会调用内核中的代码来完成操作,这时必须切换到 Ring0,然后进入 3GB-4GB 中的内核地址空间去执行这些代码完成操作,完成后,切换回 Ring3,回到用户态。这样,用户态的程序就不能随意操作内核地址空间,具有一定的安全保护作用。

系统调用

由上文我们知道操作系统分为用户态和内核态,用户态是没有权限访问内核态管理的相关资源(网络、磁盘、cpu 等),因此内核态提供一系列的接口,提供给用户态来访问这些资源,这些接口被称为系统调用

<?php
$arr = file_get_contents('./tmp');
print_r($arr);
复制代码

上面是一段很简单 php 代码,读取 tmp 文件的内容。由上文可知用户态访问磁盘必须使用系统调用,下面使用 strace 命令查看这个进程所使用的系统调用。

strace -o output -T -tt php test.php

image.png

计算机网络模型

image.png

上图是 TCP/IP 五层模型(也有说是四层,链路层和物理层统称为数据链路层)。应用程序主要位于应用层,下面四层主要由内核来使用,为什么要这样设计?

在你的电脑上,QQ、微信、邮件都可以对外部进行网络通信,这些进程在通信的时候,有很大一部分功能是一致的、重复的,因此代码不需要重复去开发。另外一个就是,网卡就那么一块,每个进程都是通过这块网卡向外发送数据,访问不同的服务器,那么如何管理好这个通用的资源,就是由操作系统内核来进行调度。

image.png

nc 命令可以指定与远程主机建立 TCP 连接(UDP 也可以),当我们与百度服务器建立 TCP 连接后,也就是说传输层及下层的协议,不需要我们来实现,而是由操作系统帮我们搞定,我们只需要实现应用层的协议即可。

image.png

image.png

nc 127.12.0.1 63790

image.png

因此,应用层的协议是用应用程序本身来实现的,比如上面所使用的 HTTP/RESP 等协议,传输层及以下的协议,是由操作系统来实现的

网络 IO 模型

BIO(Blocking IO)

<?php
$host = '127.0.0.1';
$port = '19990';
//创建 socket 
//AF_INET  表示网络层协议 IPv4
//SOCK_STREAM 表示传输层数据格式,TCP 协议基于字节流式套接字
//SOL_TCP 表示传输层协议 TCP
if (($socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP)) < 0) {
    echo "socket_create error: " . socket_strerror($socket) . PHP_EOL;
    exit();
}

//把 socket 绑定在一个 IP 和端口上
if (socket_bind($socket, $host, $port) < 0) {
    echo "socket_bind error: " . socket_strerror($socket) . PHP_EOL;
    exit();
}

//监听由指定socket的所有连接
if (socket_listen($socket, 256) < 0) {
    echo "socket_listen error: " . socket_strerror($socket) . PHP_EOL;
    exit();
}
echo "Start time:" . date('Y-m-d H:i:s') . PHP_EOL;
echo "Listening at " . $host . ':' . $port . PHP_EOL;

while (true) {
    //接收一个Socket连接
    if (($conn = socket_accept($socket)) < 0) {
        echo "socket_accept error: " . socket_strerror($conn) . PHP_EOL;
        break;
    } else {
        //连接进来了,fork 一个进程,处理该连接的消息
        if (pcntl_fork() == 0) {
            //发送到客户端
            $msg = "From server: tcp socket connect successful...\n";
            socket_write($conn, $msg, strlen($msg));
            while (true) {
                // 获得客户端的输入
                $buf = socket_read($conn, 2048);
                // 把客户端输出打印到控制台
                if ($buf === '') {
                    socket_close($conn);
                    exit(0);
                } else {
                    echo "From Cilent:{$buf}\n";
                }
            }
        }
    }
}
//关闭socket
socket_close($socket);
复制代码

主进程负责等待接收 socket 连接,一旦有 socket 连接进入以后,fork 一个子进程,子进程会等待这个 socket 发消息,收到消息后就把消息内容打印到终端。下面使用 strace 查看这些进程的系统调用情况。

strace -o output -ff -T -tt php server.php

运行 server 后我们能看到有三个进程,其中 1261 是主进程,1264 和 1267 是子进程

image.png

同时在当前目录能看到这三个进程所有系统调用输出的文件,分别查看几个进程的系统调用

image.png

image.png

image.png

image.png

能够发现主进程阻塞在 accept 系统调用,因为会一直等待连接,子进程阻塞在 recvfrom,因为一直在等待 client 发消息。

这种 IO 模型的基本原理是:主进程负责接收连接,每次有连接到来后 fork 一个子进程处理连接。这种模型最大的问题是,当服务端连接数成千上万个时,相应的子进程也会有那么多,创建进程和销毁进程会特别消耗 CPU 的资源,并且当进程数变高,CPU 相应的负载也会急剧上升

NIO (Nonblocking IO)

NIO 表示非阻塞 IO,也就是我们希望不要再 accept 和 revfrom 两个系统调用上阻塞,如果没有连接/没有消息,就返回 false。

//设置为非阻塞 IO
socket_set_nonblock($socket);
//已建立连接的 socket
$activeConn = [];
while (true) {
    //接收一个Socket连接,此时 accept 返回 false
    if (($conn = socket_accept($socket)) < 0) {
        echo "socket_accept error: " . socket_strerror($conn) . PHP_EOL;
        break;
    } else {
        //如果有连接进来,添加到 activeConn
        //如果没有连接进来,遍历 activeConn,依次读取每个 conn 的消息
        if ($conn) {
            //已经建立连接的 socket 设置非阻塞 IO
            socket_set_nonblock($conn);
            $msg = "From server: tcp socket connect successful...\n";
            socket_write($conn, $msg, strlen($msg));
            $activeConn[] = $conn;
        } else {
            if ($activeConn) {
                while (true) {
                    foreach ($activeConn as $conn) {
                        // 获得客户端的输入,此时 read 也不是阻塞的了,返回 false
                        $buf = socket_read($conn, 2048);
                        if ($buf) {
                            echo "From Cilent:{$buf}\n";
                        }
                    }
                    break;
                }
            }
        }
    }
}
复制代码

由于阻塞式 IO 会依赖于进程数来解决连接数,所以我们可以使用非阻塞 IO,来同时判断是否有连接进来,以及进来的连接是否有数据,如果没有连接进来,也没有数据进来,会一直进行 accept/recvfrom 系统调用。

strace php nio.php

当没有连接进来时,accept 系统调用不会阻塞,而是会一直返回 -1

image.png

当连接进来后,如果没有发消息,recvfrom 也不会一直阻塞,会一直返回 -1

image.png

NIO 模式的好处在于我们不需要再去通过 fork 接收连接的消息,一个进程既可以接收连接,也可以接收数据。坏处在于,由于是非阻塞的,当没有连接也没有消息的时候,系统仍然处于运行状态,这样会让 CPU 空转,白白浪费 CPU 资源

IO多路复用

有没有一种办法,既能在一个进程内处理多个连接,又不让 CPU 空转?

有,答案就是 IO 多路复用

IO 多路复用是操作系统提供的系统调用,目的是传入一个 socket 数组,通过 select 能拿到有读写事件发生的 socket,当所有 socket 都没有事件发生时,进程就会阻塞在 select 系统调用处。需要注意的是,这里的事件既包括 新的连接进来,也包括 已有的连接有数据产生

select

//需要通过 select 遍历的 socket 数组
$socketArr = [$listenSocket];
while (true) {
    // socket_select 一共四个参数
    // read 返回可读的 $socket 数组
    // write 返回可写的 $socket 数组
    // except 返回异常的 $socket 数组
    // tv_sec 设置 select 等待时间,null 表示当没有事件发生时阻塞在 select
    // https://www.php.net/manual/zh/function.socket-select
    $reads = $socketArr;
    // select 返回:若有就绪描述符则为其数目,若超时则为0,若出错则为-1
    // 最后一个参数设置 null 表示阻塞在 select 处
    if (socket_select($reads, $writes, $excepts, null) > 0) {
        //进来则表示有事件发生了
        if (in_array($listenSocket, $reads)) {
            //如果是新连接进来了,那么就调用 accept
            $conn = socket_accept($listenSocket);
            $msg = "From server: tcp socket connect successful...\n";
            socket_write($conn, $msg, strlen($msg));
            //把新连接加到 socket 数组
            $socketArr[] = $conn;
            //把监听的 socket 删掉,那么剩下的全是已经建立连接的 socket
            $key = array_search($listenSocket, $reads);
            unset($reads[$key]);
        }
        if (!empty($reads)) {
            //如果已经建立连接的 socket 有数据返回,那么遍历依次读取数据
            foreach ($reads as $conn) {
                $buf = socket_read($conn, 2048);
                if ($buf === '') {
                    //当 buf === '' 表示 client 已经断开连接
                    //这个时候就要关闭这个 socket,并且从 socketArr 删除
                    socket_close($conn);
                    $key = array_search($conn, $socketArr);
                    unset($socketArr[$key]);
                } else {
                    echo "From Cilent:{$buf}\n";
                }
            }
        }
    }
}
复制代码

开启服务

strace -T -tt php select.php

image.png

最后一行能看到,目前没有任何连接进来,也没有任何连接产生数据,当前阻塞在 select 系统调用处。细心的同学会发现,socket_select 我们传入四个参数,但是 select 系统调用有五个参数,第一个是php 底层扩展自动填入的。4 表示的是待测试的描述符个数,它的值是待测试的最大描述符加 1,这里隐藏了另外三个 FD,分别是 0(标准输入)1(标准输出)2(标准错误),当前监听的可读 socket 对应的 FD 是 [3],剩下两个则是可写的 FD 和 异常的 FD,最后一个则是设置 select 为阻塞状态。

client 建立连接

nc 127.0.0.1 19990

image.png

开启一个连接,首先看这一行

17:04:08.273968 select(4, [3], [], [], NULL) = 1 (in [3]) <213.229937>

FD3 上有事件发生了(新连接进来了),那么 select 返回的可读 FDARR = [3],因为 FD3 是监听端口的 socket,所以下一句就是 accept

17:07:41.263722 accept(3, {sa_family=AF_INET, sin_port=htons(38989), sin_addr=inet_addr(“127.0.0.1”)}, [128->16]) = 4 <0.000033>

然后又阻塞在了 select,因为进来新连接以后就没有事件产生了

client 发送数据

image.png

我们发送一下数据,这个时候 select 监听到 FDARR 有数据产生,同样会返回有事件发生的 FD4,这个时候我们从 FD4 里调用 recvfrom,取出数据,随后又阻塞在 select 处

17:12:12.667253 recvfrom(4, “hello cwh\n”, 2048, 0, NULL, NULL) = 10 <0.000443>

使用 select 函数,通知内核挂起进程,当一个或多个 I/O 事件发生后,控制权返还给应用程序,由应用程序进行 I/O 事件的处理。

描述符就绪条件

通过上面的测试能发现,我们使用 select 系统调用,内核就能告诉我们哪些 FD 上有事件发生,那么我最大的疑惑就是,内核是怎么知道哪些 FD 有事件发生的?

UNIX 网络编程中,有这么一段解释:

当满足下列条件之一时,一个套接字准备好读:

a)该套接字接收缓冲区中的数据字节数大于等于套接字接收缓冲区低水位标记的当前大小。对这样的套接字执行读操作不会阻塞并将返回一个大于 0 的值(也就是返回准备好读入的数据)。我们可以使用 SO_RCVLOWAT 套接字选项设置该套接字的低水位标记。对于 TCP 和 UDP 套接字而言,其默认值为 1

b)该连接的读半部关闭(也就是接收了 FIN 的 TCP 连接)。对这样的套接字的读操作将不阻塞并返回 0 (也就是返回 EOF)

c)该套接字是一个监听套接字且已完成的连接数不为 0。对这样的套接字的 accept 通常不会阻塞

d)其上有一个套接字错误待处理。对这样的套接字的读操作将不阻塞并返回 -1(也就是返回一个错误),同时把 errno 设置成确切的错误条件。这些待处理错误也可以通过指定 SO_ERROR 套接字选项调用 getsockopt 获取并清除

书中的解释比较专业,我个人的理解是,不应该从应用程序的角度去思考,比如当 client 通过 TCP 连接发送数据,这个连接对应的 FD 就是可写的,这种思路其实不对。而是应该从套接字本身出发,select 检测套接字可写,完全是基于套接字本身的特性来说,当套接字本身的状态产生了变化,由此内核判断套接字是否可读可写

总结一下,我们把监听端口的 socket 和建立好连接的 socket 一起丢到 FDARR 里面,通过 select 系统调用告诉我们哪些 FD 有事件发生,如果是 监听端口的 FD,那么就进行 accept,如果是建立好连接的 FD,那么就进行 recvfrom。这样既不需要开启子进程处理已连接的 socket,也不需要设置 NIO 让 CPU 进行空转

select 的缺点

select 这么牛逼,为什么高性能的 Application 都用 epoll?

  • 单个进程所打开的FD是有限制的,通过 FD_SETSIZE 设置,默认1024
  • 每次调用select,都需要把 fd 集合从用户态拷贝到内核态,这个开销在fd很多时会很大
  • 对socket扫描时是线性扫描,采用轮询的方法,效率较低(高并发时)

poll

poll 解决了 select 对于 FD_SETSIZE 的限制,然而对于检测可读可写的 FD 依旧是放入全部 FD 集合,然后轮询每一个 FD,因此在高并发下效率依然不高

缺点

  • 每次调用poll,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
  • 对socket扫描时是线性扫描,采用轮询的方法,效率较低(高并发时)

epoll

上文提到的 select 存储 fd 的数据结构为 bitmap,poll 存储 fd 的数据结构为 array,但是两者监听 fd 的状态都是采用轮询的方法,因此都需要做线性扫描,因此在高并发时,如果大量的 fd 集合中只有一个 fd 有事件产生,那么仍需要遍历整个集合

epoll 存储 fd 的数据结构为红黑树,并且单独为有事件已经就绪的 fd 设置了一个存储结构——链表

那么,这个准备就绪链表是怎么维护的呢?

当我们执行 epoll_ctl 时,除了把 fd 放到 epoll 文件系统里对应的红黑树上之外,还会给内核中断处理程序注册一个回调函数,告诉内核,如果这个 fd 的中断到了,就把它放到准备就绪链表里。所以,当一个 fd 上有数据到了,内核在把网卡上的数据复制到内核中后就来把 fd 插入到准备就绪链表里了

epoll 正是基于在 CPU 的软件中断上注册回调函数,从而能够知道哪一个 fd 状态发生改变,从而能够拿到一系列就绪的 fd 集合。因此,相比 select 和 poll,epoll 具有更高的性能以及更低的资源消耗,但是 epoll 只能工作在 linux 下

图片描述

github

参考

[1]Unix 环境高级编程

[2]Unix 网络编程

作者:夜深忽梦少年事丶
链接:https://juejin.cn/post/6957619370444980232
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消