这篇文章,主要讲讲非堵塞编程带给程序的意义。
在我们谈到今天的主题之前,先来做一点基础知识的补充。
什么是I/O
我们的计算机系统架构简易可看成如下,I/O接口连接其他硬件如:网卡、键盘鼠标、磁盘等。
I代表Input,输入数据。 O代表Output,输出数据。
当程序需要发送网络请求或者从磁盘中读取文件等IO操作时 CPU发出指令,然后信号经过总线到达网卡或者磁盘 然后拿到数据,再经过总线到达主存中,CPU继续对主存中的数据进行操作。
CPU的执行速率:主频 比如3GHz = 一秒钟有30亿个时钟脉冲,执行一条指令一般只需要几个时钟脉冲。也就是一秒可以执行的指令经常是以亿计算的。
以网络请求为例(磁盘IO也是一样的原理),当CPU发出指令之后,想要得到结果需要经过很长的等待(比如网络延迟经常是几十ms时间,CPU都过了多少千万个时钟脉冲了)
同步、异步、堵塞、非堵塞的概念
相信看这篇文章的你也不是第一次看到这种概念,在很多文章中经常会以购物等场景做例子。
这里只做一个简单的介绍:
同步、异步
分为一组概念;
堵塞、非堵塞
分为一组概念;
(同步、异步):关注的是:数据的接收方式 (堵塞、非堵塞):关注的是:是否等待结果返回
这是两个分组(因为它们的关注点不同)
但是往往同步跟堵塞
是一起的,异步跟非堵塞
是一起的。
如果我们需要同步接收数据,肯定要让当前程序暂停,等待数据返回再做处理。 如果我们选择了异步接收数据,程序还堵塞的话那就没什么意义了,所以非堵塞模式,一般会返回发送调用请求的结果,然后程序继续执行,直到结果准备好了,再通过回调函数等方式触发程序做处理。
堵塞IO存在的不足
如果是堵塞IO的话,那么当前的进程会暂停
执行,直到拿到数据才会继续执行。
文件锁堵塞
以PHP中自带的Session为例的文件锁
Session以生成文件储存的,如果同一个用户同时发起多个请求,先获取文件锁的请求可以执行,后面的拿不到文件锁,所以一直堵塞等待,假设前面的请求过了10s才执行完,后续的请求是要10s后才开始
执行。
socket堵塞
写过tcp服务器的应该都会遇到这个问题
我们可以监听机器的某个端口,当有请求连接进来的时候,我们可以accept这个连接,然后读取客户端发过来的数据、发送数据回客户端等处理。
代码语言:javascript复制<?php
$socket = stream_socket_server("tcp://0.0.0.0:8000", $errno, $errstr);
if (!$socket) {
echo "$errstr ($errno)<br />n";
} else {
// 循环接收客户端的连接
while ($conn = stream_socket_accept($socket)) {
$data = fread($conn, 8192); // 读取客户端发送过来的数据 读不到就一直堵塞着
fwrite($conn, "hello worldn"); // 发送hello world
fclose($conn);
}
fclose($socket);
}
以上代码实现了一个建议的TCP服务器,但是因为没有解决堵塞IO的问题,所以只能处理一个客户端的请求。
- 当A连接进来,accept到,然后开始fread从缓冲区读取数据。 堵塞住了,进程执行暂停,等待数据结果。
- 此时B连接进来,因为进程已经被堵塞住,所以无法被accept,更无法读取、发送数据。
- A客户端发送了数据,进程恢复执行,开始读取,然后输出。
- 然后才能accept B客户端(哪怕在此之前B已经发了很多数据,也只能从这个时候开始处理)。
非堵塞IO
为了让我们的网络服务器可以服务多个客户端,我们需要将程序改造为非堵塞的。
我们可以简单实现为:
- 当A连接进来了,accept起来,存到一个列表中。
- 继续等待监听,B连接进来了,accpet起来,存到一个列表中。
- 多开一个线程,不断轮询连接列表,判断连接是否有发送数据过来,有的话就执行操作(比如发送数据、关闭连接)
- 在PHP中默认没有线程操作,并且accept操作是堵塞的,但是可以设置
超时时间
所以我们可以让程序每等待0.1s连接进来,然后就去轮询一次连接列表,读取数据然后操作。
代码语言:javascript复制<?php
$socket = stream_socket_server("tcp://0.0.0.0:8000", $errno, $errstr);
if (!$socket) {
echo "$errstr ($errno)<br />n";
} else {
$conns = [];// 全局连接
while (true){
$conn = @stream_socket_accept($socket, 0.1); // 0.1没有连接进来就不堵塞等待了 先检测有没有客户端发数据
if($conn!== false){
$conns[] = $conn;
stream_set_blocking($conn, false);
}
foreach ($conns as $key=>$item) {
$data = fread($item, 8192);
if ($data !== ''){
fwrite($item, "hello");
fclose($item);
unset($conns[$key]);
}
}
}
fclose($socket);
}
以上的I/O模型是同步非堵塞
,当客户端连接数比较多的时候,以上代码还是有很大的问题。
我们还可以将对客户端的操作逻辑进行异步执行(因为我们的实际业务逻辑肯定不只是输出hello这么简单,还要数据库操作等等)
将对客户连接的操作逻辑异步分离的话,但是accept连接还是堵塞同步的,因此可见,程序同步、异步、堵塞、非堵塞是相对的,需要按功能点和模块来分析。
我们也可以依赖扩展,比如Event
等,实现异步非堵塞
模型。
当有客户连接、断开、读写数据时,底层扩展会通过我们设置的回调函数触发,而不需要我们在程序代码中accpet、read(堵塞或者轮询)
可以参考简单的demo。
这不是完整的demo,并且需要安装扩展,大家了解一下使用的方式即可 有兴趣可以继续深入学习Event扩展的使用
class MyListenerConnection {
private $bev, $base;
public function __destruct() {
$this->bev->free();
}
// 新链接进来 并且监听 这个时候就设置链接的事件回调
public function __construct($base, $fd) {
$this->base = $base;
$this->bev = new EventBufferEvent($base, $fd, EventBufferEvent::OPT_CLOSE_ON_FREE);
// 设置回调事件
$this->bev->setCallbacks(
array($this, "echoReadCallback"),
array($this, "writeCallback"),
array($this, "echoEventCallback"),
NULL
);
if (!$this->bev->enable(Event::READ)) {
echo "Failed to enable READn";
return;
}
}
// 读回调
public function echoReadCallback($bev, $ctx) {
// 在这里处理 handleRequest $bev->input就是客户端发送的数据
$bev->output->addBuffer($bev->input);
// $bev->output设置内容就是会发送给客户端的数据 这里原样返回
}
// 写回调 是输出之后才回调的 而不是在输出之前
public function writeCallback($bev, $ctx){
// 释放监听 断开连接
$bev->free();
}
// 除了读写之外其他事件的回调
public function echoEventCallback($bev, $events, $ctx) {
if ($events & EventBufferEvent::ERROR) {
echo "Error from buffereventn";
}
if ($events & (EventBufferEvent::EOF | EventBufferEvent::ERROR)) {
$this->__destruct();
}
}
}
通过这种方式,我们写一个网络服务器就很简单了,只需要给事件设置回调事件,由底层维护客户端连接的可读写状态, 这种模型是I/O复用里的epoll
模型。
总结
通过上面文件锁、几种TCP服务器的写法,我们可以理解到堵塞和非堵塞程序之间的区别了。
再做一下小小的总结。
- 同步和异步是指
决定结果返回的接收方式
- 堵塞和非堵塞是指
是否需要等待结果返回
- 如果发生磁盘IO等操作,因为CPU执行速率和总线信号传递、磁盘速率的不对等,CPU如果堵塞等待读取结果,就不能最大化地利用机器资源。
- 非堵塞程序,可以提高机器的利用率,可以提高并发支持。
- 常见的I/O模型有:阻塞式I/O;非阻塞式I/O;I/O复用(select和poll);异步I/O;