颤抖开篇,从php角度谈谈IO模型(BIO)
IO 是什么?
在计算机系统中I/O就是输入(input)和输出(Output)的意思。针对不同的操作对象,可以划分为磁盘I/O模型,网络I/O模型,内存映射I/O,Direct I/O、数据库I/O等,只要具有输入输出类型的交互系统都可以认为是I/O系统
,也可以说是整个操作系统数据交换与人机交互的通道,这个概念与选用的开发语言没有关系,是一个通用概念。
谈谈 (阻塞)
在学习 IO 中必须要搞懂的几个概念:(阻塞,非阻塞)与(同步,异步)
。
本篇文章只介绍阻塞,其余几个概念将在后面篇章中挨个介绍学习。
在了解阻塞IO前,我们先看看网络数据包接收流程,在这里我们可以将整个流程总结为两个阶段:
-
数据准备阶段:
在这个阶段,网络数据包到达网卡,通过DMA的方式将数据包拷贝到内存中,然后经过硬中断,软中断,接着通过内核线程ksoftirqd经过内核协议栈的处理,最终将数据发送到内核Socket的接收缓冲区中。 -
数据拷贝阶段: 当数据到达内核Socket的接收缓冲区中时,此时数据存在于内核空间中,需要将数据拷贝到用户空间中,才能够被应用程序读取。
阻塞
阻塞主要发生在第一阶段:数据准备阶段。
当应用程序发起系统调用 read
读操作时,线程从用户态转为内核态,读取内核Socket的接收缓冲区中的网络数据。
如果这时内核Socket的接收缓冲区没有数据,那么线程就会一直等待,直到Socket接收缓冲区有数据为止。随后将数据从内核空间拷贝到用户空间,系统调用 read
返回。
从图中我们可以看出:阻塞的特点是在第一阶段和第二阶段都会等待。
阻塞IO(BIO)
经过前一小节对阻塞这个概念的介绍,相信大家可以很容易理解阻塞IO的概念和过程。
既然这小节我们谈的是IO,那么下边我们来看下在阻塞IO模型下,网络数据的读写过程。
阻塞读
当用户线程发起read系统调用,用户线程从用户态切换到内核态,在内核中去查看Socket接收缓冲区是否有数据到来。
Socket接收缓冲区中有数据,则用户线程在内核态将内核空间中的数据拷贝到用户空间,系统IO调用返回。
Socket接收缓冲区中无数据,则用户线程让出CPU,进入阻塞状态。当数据到达Socket接收缓冲区后,内核唤醒阻塞状态中的用户线程进入就绪状态,随后经过CPU的调度获取到CPU quota进入运行状态,将内核空间的数据拷贝到用户空间,随后系统调用返回。
阻塞写
当用户线程发起send系统调用时,用户线程从用户态切换到内核态,将发送数据从用户空间拷贝到内核空间中的Socket发送缓冲区中。
当Socket发送缓冲区能够容纳下发送数据时,用户线程会将全部的发送数据写入Socket缓冲区,然后执行在网络包发送流程,然后返回。
当Socket发送缓冲区空间不够,无法容纳下全部发送数据时,用户线程让出CPU,进入阻塞状态,直到Socket发送缓冲区能够容纳下全部发送数据时,内核唤醒用户线程,执行后续发送流程。
阻塞IO模型下的写操作做事风格比较硬刚,非得要把全部的发送数据写入发送缓冲区才肯善罢甘休。
由于BIO 的阻塞特性,想让BIO 同时为多个客户端服务,每个请求都需要被一个独立的线程处理。一个线程在同一时刻只能与一个连接绑定。来一个请求,服务端就需要创建一个线程用来处理请求。
阻塞IO模型
在早期 Java 中 BIO 的实现就是一个客户端连接创建一个线程来处理请求,由于php 对多线程支持不是特别好,在php 中如何实现BIO呢? 咱们先来个例子
// 创建 ipv4 tcp socket
$sockfd = socket_create(AF_INET, SOCK_STREAM, 0);
if (!is_resource($sockfd)) {
fprintf(STDOUT, "socket create fail:%s\n", socket_strerror(socket_last_error($this->_sockfd)));
}
// 绑定地址 端口
socket_bind($sockfd,'0.0.0.0',6379);
// 监听 socket
socket_listen($sockfd,10);
while (1){
// 第一个阻塞函数 获取客户端连接,没有客户端连接会一直阻塞,阻塞状态的进程是不会占据CPU的
$connfd = socket_accept($sockfd);
if(empty($connfd)){
continue;
}
// 第二个阻塞函数 , 没有数据会一直阻塞,直到客户端发送数据过来,才往下执行
$buff = socket_read($connfd,1024);
fprintf(STDOUT,"%s",$buff);
// 向客户端发送一个helloworld
$msg = "helloworld\r\n";
// 向客户端发送数据
socket_write($connfd, $msg, strlen( $msg ) );
echo time().' : a new client'.PHP_EOL;
// 服务端 主动断开客户端连接
socket_close($connfd);
}
// 关闭 监听 socket
socket_close($sockfd);
简单解析一下上述代码来说明一下tcp socket服务器的流程:
- 首先,根据协议族(或地址族)、套接字类型以及具体的的某个协议来创建一个socket。
- 第二,将上一步创建好的socket绑定(bind)到一个ip:port上。
- 第三,开启监听linten。
- 第四,使服务器代码进入无限循环不退出,当没有客户端连接时,程序阻塞在accept
上,有连接进来时才会往下执行,接着阻塞在read 上,客户端发送数据才会往下执行 ,然后再次循环下去,为客户端提供持久服务。
上面这个案例中,有两个很大的缺陷:
- 一次只可以为一个客户端提供服务,如果第一个客户端连接没有发送数据,导致一直阻塞在 read 上,这时有第二个客户端来连接,那么第二个客户端就必须要等待第一个连接发送数据才行。
- 很容易受到攻击,造成拒绝服务。
分析了上述问题后,又联想到了前面说的多进程,那我们可以在accpet到一个请求后就fork一个子进程来处理这个客户端的请求,这样当accept了第二个客户端后再fork一个子进程来处理第二个客户端的请求,这样问题不就解决了吗?
// 创建 ipv4 tcp socket
$sockfd = socket_create(AF_INET, SOCK_STREAM, 0);
if (!is_resource($sockfd)) {
fprintf(STDOUT, "socket create fail:%s\n", socket_strerror(socket_last_error($this->_sockfd)));
}
// 绑定地址 端口
socket_bind($sockfd,'0.0.0.0',6379);
// 监听 socket
socket_listen($sockfd,10);
while (1){
// 第一个阻塞函数 获取客户端连接,没有客户端连接会一直阻塞,阻塞状态的进程是不会占据CPU的
$connfd = socket_accept($sockfd);
if(empty($connfd)){
continue;
}
$pid = pcntl_fork();
if($pid == 0){
// 第二个阻塞函数 , 没有数据会一直阻塞,直到客户端发送数据过来,才往下执行
$buff = socket_read($connfd,1024);
fprintf(STDOUT,"%s",$buff);
// 向客户端发送一个helloworld
$msg = "helloworld\r\n";
socket_write($connfd, $msg, strlen( $msg ) );
// 休眠5秒钟,可以用来观察时候可以同时为多个客户端提供服务
echo time().' : a new client'.PHP_EOL;
socket_close($connfd);
}
}
// 关闭 监听 socket
socket_close($sockfd);
通过 fork 多进程的确可以同时服务多个客户端,但当客户端请求的并发量突然增大时,服务端在一瞬间就会创建出大量的进程,而创建进程是需要系统资源开销的,这样一来就会一瞬间占用大量的系统资源。
如果客户端创建好连接后,但是一直不发数据,通常大部分情况下,网络连接也并不总是有数据可读,那么在空闲的这段时间内,服务端进程就会一直处于阻塞状态,无法干其他的事情。CPU也无法得到充分的发挥,同时还会导致大量进程切换的开销。
编写一个例子模拟大量客户端连接
for ($i = 10000; $i < 65000; $i++){
// 创建 socket
$_sockfd = socket_create(AF_INET, SOCK_STREAM, 0);
// 绑定客户端 ip地址 端口
socket_bind($_sockfd,'192.168.0.102',$i);
// 连接服务端
if(socket_connect($_sockfd,'111.230.247.213',6379)){
fprintf(STDOUT,"客户端连接成功 ip=%s\n",'192.168.0.102:'.$i);
}
}
执行客户端模拟连接脚本
服务端的确可以同时处理很多请求,但是也创建了大量进程,消耗大量系统资源与同时还会导致大量进程切换的开销。
所以,我们就再次提出增进型解决方案。我们可以预估一下业务量,然后在服务启动的时候就fork出固定数量的子进程,每个子进程处于无限循环中并阻塞在 accept
上,当有客户端连接挤进来就处理客户请求,当处理完成后仅仅关闭连接但本身并不销毁,而是继续等待下一个客户端的请求。这样,不仅避免了进程反复fork销毁巨大资源浪费,而且通过固定数量的子进程来保护系统不会因无限fork而崩溃,其实这就是资源池化解决方案。
$sockfd = socket_create(AF_INET, SOCK_STREAM, 0);
if (!is_resource($sockfd)) {
fprintf(STDOUT, "socket create fail:%s\n", socket_strerror(socket_last_error($this->_sockfd)));
}
socket_bind($sockfd,'0.0.0.0',6379);
socket_listen($sockfd,10);
// 按照数量fork出固定个数子进程
for( $i = 1; $i <= 10; $i++ ){
$pid = pcntl_fork();
if( 0 == $pid ){
cli_set_process_title('phpserver worker process');
while( true ){
$conn_socket = socket_accept( $sockfd );
$msg = "helloworld\r\n";
socket_write($conn_socket, $msg, strlen( $msg ) );
socket_close($conn_socket);
}
}
}
// 父进程回收子进程退出,回收资源
while( true ){
$pid = pcntl_wait($status);
if($pid > 0){
fprintf(STDOUT,"PID=%d 子进程退出了",$pid);
}
}
socket_close($sockfd );
启动php BIO 服务端 ,通过 ps -ef|grep phpserver
命令查看阻塞在 socket_accept
等待处理客户端连接的 10 个子进程
预先创建10个子进程 处于等待服务状态,再同一个时刻可以同时为10个客户端提供服务。
适用场景
基于以上阻塞IO模型的特点,该模型只适用于连接数少,并发度低的业务场景。
比如公司内部的一些管理系统,通常请求数在100个左右,使用阻塞IO模型还是非常适合的。而且性能也不错。
文章部分内容参考文献
- https://mp.weixin.qq.com/s/zAh1yD5IfwuoYdrZ1tGf5Q