这篇文章,主要讲讲非堵塞编程带给程序的意义。

在我们谈到今天的主题之前,先来做一点基础知识的补充。

什么是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这个连接,然后读取客户端发过来的数据、发送数据回客户端等处理。

<?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 world\n"); // 发送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连接进来,然后就去轮询一次连接列表,读取数据然后操作。

<?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 READ\n";
            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 bufferevent\n";
        }

        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;