大家好,我就是那个前两天招呼打得太骚、现如今已不知如何开场的老李。
在第八章和第九章的案例中,哥用socket和fork等基础为为大家表演了如下一波儿:
仔细感受一下,这个服务器的并发能力全靠fork()。假如说平均完成一次请求会话业务处理平均耗时一秒钟,那么理论上十个进程在一秒钟时间粒度内同时最多支撑十个客户端的请求会话处理。
也就是说,我要在一秒钟时间粒度内平均伺候一万个客户端,就要开TA个一万个进程(线程)左右... ...
到这里是时候又得回到我这个公众号名字的flag上了,这个[ 高性能API社区 ]无疑就是个巨大的flag,为了能平均一秒钟能伺候一万个客户端就得开一万个进程,这叫[ 高性能API ]?...与高性能半毛钱关系都没有,好么?你们甚至都有想法让我换个公众号名字了,比如:
2020年让我们一起重立这个高性能flag
总算写到select系统调用了,咱老李腰板也总算xue微xue微能直起来一点点了,不管咋说,多多少少算是和高性能挨上点儿边儿了,至少是和高IO能挨上点儿边儿了。
今天要说的这个东西叫做IO multiplexing,中文翻译叫做IO多路复用。按我个人理解[ 多路 ]是指一大坨多个连接请求,[ 复用 ]是指这么多的一坨请求共同占用一个或少数几个进程(线程),我这么一[ 歪解 ]大家是不是突然觉得还行...
据说在很久很久之前,当时的世界上有一个传说中的c10k难题,由于这个难题的存在,(此处老李开始开启传说小道消息模式)所以当时(大概就是2001到2004年左右)正在起飞的腾讯QQ的主要矛盾就是日益爆炸级增长的用户量与低性TCP服务器的矛盾,为啥捏?前面我们说过了利用进程(线程)实现TCP服务器,有一个结论就是只能用一个进程(线程)去支撑一个客户端链接,老李那会儿逃课去的网吧大概平均数量能维持在400台左右,光伺候一个网吧就要搞400个进程(线程)?所以当时腾讯退而求其次,采用了UDP协议,为了尽量保证UDP无限向TCP服务质量靠拢,所以有时候我就感慨老一代的程序员是真正的程序员,哪儿像我们现在似的,从开始百度nodejs绕开前三个培训班狗皮小广告到打开搜索到的CSDN那些默认只打开半屏的劣质文章再加上复制带粘贴一共耗时三十秒、一共五六行代码就能启动一个服务器。但是后来事情出现了转折,那就是2003年左右标志性的Linux Kernel 2.6,这个版本的Linux Kernel搭载了一种叫做epoll的核武器(之前其实也有搭载不过都是处于dev状态中的epoll)。epoll终于完美解决了*NIX平台下的c10k问题,所以腾讯也迅速跟进了。证据是什么?证据就是老李作为一个老皮还依稀记得那会儿腾讯QQ登录窗口后来提供了一个登录选项叫做服务器配置,这个选项有两个值:
而当时一些刚兴起来的电脑知识论坛(什么电脑爱好者论坛、网友世界什么的)开始流传起“ 选择TCP服务器 ”可以让聊天更快更稳定的小道传说,虽然大部分的初代网民们压根就不知道到底啥叫TCP。老李那会儿逃课去网吧通宵的时候,晚上打一圈CS、魔兽争霸III后没啥事儿了就喜欢跟网管扯淡,从使用//192.168.1.xxx/share/con/con让对方的Windows 9X设备死机、到如何用X-Scan之类的扫描公网上开着3389弱口令的Windows 2000系主机、到如何流畅痛快地使用著名软件[ PP点点通 ]、[ 哇嘎画时代 ]...啥都能聊,高兴地时候都会帮网管用GHOST给出了问题的机器做盗版XP系统用来换取下一次通宵时候的五折折扣优惠券。
记得后来有一天,我跟老肉、红旗一行五六个人又一起跑路去网吧通宵打CS,一边加两个robot开5V5,地图就是那张经典的沙漠2。我跟红旗还有志超坐一排,老肉他们几个坐在我们对面一排,半拉网吧都沉浸在我们几个一边儿激情地吼叫声中。大概打到凌晨四点多的时候,突然门口闯进来几个人,然后也不知道怎么着就和老肉后面那一排的几个人干起来了,双方打得贼激烈拿键盘扛着凳子互相攻击,当时我和红旗都看呆了,志超跟老肉一看我俩在别人打架就吼了一声“ 你们俩TM快点儿啊!我TM炸弹都装好了! ”,然后我们几个继续埋头打CS,那几个人也继续拿着键盘举着凳子打架。大概到早上五点多的时候,网吧老板火急火燎地来了,我们几个往后一看地上还躺着一个家伙,一脸都是血。老肉说“ 咱们赶紧回去吧,一会儿就赶不上早自习了 ”,于是我们就跑了。
逐渐忘记标题... ... ......
其实也不用对IO多路复用产生恐惧感,这种东西严格意义上说也不算是什么黑科技,要知道复用这种概念其实在通信行业非常常见,即便在我们日常生活里也是常见的很,这是一种思想,下面我尝试用一个不太符合生活常识的例子来说明阐述下[ select多路复用 ]到底大概是怎么个意思。
比如你想做一个国际手纸品牌横向大评测,于是你从并夕夕买了一大坨一百种不同品牌的手纸,然后你就开始在家里等这一百个快递送上门,你这会儿就不断地轮流给这一百个快递小哥打电话问快递是否已到,你觉得这么做xue微有点儿沙雕有点儿低效。于是你买了一包白沙送给了门卫大爷并告诉TA如果有你的快递,就临时替你签收后通知你一下,这样你就解放了。但是,这个大爷唯一不好的地方就是,每次有快递替你签收后,他并不告诉你具体是哪个快递的手纸到了,你必须自己对着一百个物流信息查一遍才知道这次是具体是哪个快递到了。
翻译一下:有一百个客户端会话连接了上来,然而服务器中只有一个进程(线程)来服务这一百个客户端,这本是不可能的,然而进程(线程)可以通过利用IO复用来实现这个原本不可能的任务。IO复用可以返回给调用进程(线程)这一百个客户端会话中哪个可以读取消息了、哪个可以写消息了(在这里我要补充的是,众所周知*NIX中一切皆为文件。所以在UNP或者APUE中,一个客户端连接到服务器上实际上就会形成一个叫做文件描述符fd的东西,后面服务器从客户端读消息或写消息实际上就是读写这个文件描述符)。
目前常见的IO多路复用方案有select、poll、epoll、kqueue,这几个方案的关系大概是这样的:
select系统调用,这个玩意是我们今天的主角。在PHP里,操作select的函数叫做socket_select()或者stream_select(),我把socket_select()原型复制粘贴过来你们先感受一下:
// 这种参数我前面提到过,在apue里这种用法叫做
// 值-结果 参数
socket_select ( array &$read ,
array &$write ,
array &$except ,
int $tv_sec [, int $tv_usec = 0 ]
)
// 将想要关注可读socket保存到read中,但是函数本身又会修改read参数内容
// 比如你将 四个socket 保存到了read中,表示select要监听这 四个 socket
// 上的可读事件。但是如果只有两个socket上出现了可读,那么select就会
// 将这两个socket保存到read中,也就是read会被从有四个socket修改变成
// 只有两个socket,所以此处一定要注意!
// 实际上在PHP里不算是fd,而是一种叫做resource的概念
// 本质上依然是fd
read
The sockets listed in the read array will be watched to see if characters become available for reading (more precisely, to see if a read will not block - in particular, a socket resource is also ready on end-of-file, in which case a socket_read() will return a zero length string).
// 将想要关注可读socket保存到read中
// 实际上在PHP里不算是fd,而是一种叫做resource的概念
// 本质上依然是fd
write
The sockets listed in the write array will be watched to see if a write will not block.
// 异常的fd
except
The sockets listed in the except array will be watched for exceptions.
// 超时时间
tv_sec
The tv_sec and tv_usec together form the timeout parameter. The timeout is an upper bound on the amount of time elapsed before socket_select() return. tv_sec may be zero , causing socket_select() to return immediately. This is useful for polling. If tv_sec is NULL (no timeout), socket_select() can block indefinitely.
tv_usec
你们还记得上篇结尾那个遗留的问题吗?就是如果将listen-socket设置为非阻塞,CPU几乎就会跑满。这就是非阻塞的特点,我再次唠叨一遍:当listen-socket被设置为非阻塞IO后,我们使用socket_accept( listen-socket )从检测客户端连接时就会从原来的[ 如果没有客户端连接就会阻塞在这里一直等到有客户端连接 ]变为[ 如果没有新客户端连接就会立马返回从而继续进行一下尝试 ],这个过程有点儿类似于不断不断不断while。所以说,如果你想要使用非阻塞,就要搭配上IO复用这种“ 异步 ”(为什么加上双引号是因为IO复用其实并不是满足POSIX定义的异步,但此后我们依然先按照异步来称呼)方案,到此为止,我们终于逐渐凑齐全了网络上常见的[ 异步非阻塞 ]。
那么为什么说[ 非阻塞IO ]要搭配这种基于事件的[ 异步 ]IO多路复用呢?还回到listen-socket上来,由于当前TA已经被设置成了非阻塞IO,所以CPU会不断不断不断while accept。但是此时如果此时将listen-socket加入到IO多路复用事件中,由于只有当可读(也就是有新客户端连接时)IO复用才会告知调用方,那么此时再去accept就一定能够获取到新客户端,这样就完美地避免了打空炮行为。
小试牛刀一把,先用select实现一个基于TCP长连接的异步非阻塞聊天室你们感受一下:
<?php
$host = '0.0.0.0';
$port = 6666;
$listen_socket = socket_create( AF_INET, SOCK_STREAM, SOL_TCP );
// 如果你对之前的文章中的内容真的动手实践了的话,你就有一定概率
// 会遇到这个错误提示:address already in use
// 将SO_REUSEADDR设置为1,这样这个地址就可以被反复使用了
socket_set_option( $listen_socket, SOL_SOCKET, SO_REUSEADDR, 1 );
// 将SO_REUSEPORT设置为1,可以重复利用某个端口
socket_set_option( $listen_socket, SOL_SOCKET, SO_REUSEPORT, 1 );
socket_bind( $listen_socket, $host, $port );
socket_listen( $listen_socket );
// 将listen-socket设置为非阻塞
socket_set_nonblock( $listen_socket );
//socket_getsockname( $listen_socket, $addr, $port );
echo 'Chatroom - '.$addr.':'.$port.PHP_EOL;
// 将listen-socket加入到client数组中
$client = array( $listen_socket );
while ( true ) {
// 将client赋值给read
// 由于client中包含listen-socket
// 所以read中也会包含listen-socket
// 为什么还需要一个client数组呢?
// 因为select会修改read中,所以client相当于是
// 一种备份。client中保存的就是你所有想要监听
// 的socket,但是并不是每个socket都会有可读发生
// 但是select会修改read数组,将可读的socket
// 保存到read中,下次循环重新开始的时候,read需要
// 从client中再次将所有需要监听可读的socket全部
// 拿过来
// 如果你没看明白,看看我上面函数原型里的注释
$read = $client;
$write = array();
$exception = array();
// ⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️
// 注意,系统会被阻塞在socket_select()上
// 一直到有 可读、可写等事件发生的时候
// 调用才会返回,并同时将可读、可写等数据自动保存
// 到read、write等数组中,ret返回结果是可读可写等
// 数量。
// 对于listen-socket而言,select会调用方监控
// 发生在listen-socket上的可读事件,即有新客户端连接
// 连接上来了
$ret = socket_select( $read, $write, $exception, NULL );
//print_r( $read );
//echo "select-loop : {$ret}".PHP_EOL.PHP_EOL.PHP_EOL;
if ( $ret <= 0 ) {
continue;
}
// 就是说,如果 listen-socket 中有事件,listen-socket能有啥事件:就是用新的客户端来了
if ( in_array( $listen_socket, $read ) ) {
$connection_socket = socket_accept( $listen_socket );
if ( !$connection_socket ) {
continue;
}
socket_getpeername( $connection_socket, $client_ip, $client_port );
echo "Client {$client_ip}:{$client_port}".PHP_EOL;
// 将新连接上来的客户端的socket保存到client中
$client[] = $connection_socket;
// 将listen-socket从read中手工移除掉,因为后面
// 要开始从connection-socket中读取数据了
// listen-socket上只能做accept操作不能做read操作
$key = array_search( $listen_socket, $read );
unset( $read[ $key ] );
}
// 对于其他的connection-socket
foreach( $read as $read_key => $read_fd ) {
// 读取数据吧~~~
socket_recv( $read_fd, $recv_content, 1024, 0 );
if ( !$recv_content ) {
echo "客户端 {$read_fd} 丢失".PHP_EOL;
unset( $client[ $read_key ] );
socket_close( $read_fd );
continue;
}
$recv_content = "{$read_fd}说:".$recv_content;
// 将收到的消息广播给除了自己以外的其他所有在线客户端,其实也就是除了自己fd之外的其他所有fd
foreach( $client as $fd_item ) {
if ( $fd_item == $listen_socket ) {
continue;
}
if ( $fd_item != $read_fd ) {
echo "发送给{$read_fd}".PHP_EOL;
socket_write( $fd_item, $recv_content, strlen( $recv_content ) );
}
}
// 下面三行注释是啥意思?
// 如果开着的话,客户端就会被断开连接了
// 聊天室是需要长连接的,不能断开
//unset( $client[ $read_key ] );
//socket_shutdown( $read_fd );
//socket_close( $read_fd );
}
}
跑一下结果如下,你们感受一下(友好提示Windows用户 - Windows下应该是不可用、WSL可能也不好使):
你们先慢慢感受一下,下篇我们将基于这份代码实现一个HTTP服务器。