6.3 非阻塞 I / O
在需要处理大量连接的服务器上,如果使用线程的话,内存负荷和线程切换的开销都会变得非常巨大。因此,监听“有输入进来”等事件并进行应对处理,采用单线程来实现会更加高效。像这样通过“事件及应对处理”的方式来工作的软件架构,被称为事件驱动模型(event driven model)。
这种模型虽然可以提高效率,但也有缺点。在采用单线程来进行处理的情况下,当事件处理过程中由于某些原因需要进行等待时,程序整体就会停止运行。这也就意味着即便产生了新的事件,也无法进行应对了。
像这样处理发生停滞的情况被称为阻塞。阻塞多半会在等待输入输出的时候发生。对于事件驱动型程序来说,阻塞是应当极力避免的。
何为非阻塞 I/O
由于大部分输入输出操作都免不了会遇到阻塞,因此在输入输出时需要尤其注意。输入输出操作速度并不快,因此需要进行缓冲。当数据到达缓冲区时,读取操作只需要从缓冲区中将数据复制出来就可以了。
在缓冲机制中,有两种情况会产生等待。一种是当缓冲区为空时,需要等待数据到达缓冲区(读取时);另一种是在缓冲区已满时,需要等待缓冲区腾出空间(写入时)(图 1)。这两种“等待”就相当于程序停止工作的“阻塞”状态。
图 1 输入输出中发生阻塞的原因
尤其是在输入(读取)时,如果在数据到达前试图执行读取操作,就会一直等待数据的到达,这样肯定会发生阻塞。
相比之下,输出时由于磁盘写入、网络传输等因素,也有可能会发生阻塞,但发生的概率并不高。而且即便发生了阻塞,等待时间也相对较短,因此不必过于在意。
要实现非阻塞的读取操作,有下列几种方法。
· 使用 read(2) 的方法
· 使用 read(2)+select 的方法
· 使用 read(2)+O_NONBLOCK 标志的方法
· 使用 aio_read 的方法
· 使用信号驱动 I/O 的方法 这些方法各有各的优缺点,我们来逐一讲解一下。
使用 read(2) 的方法
首先,我们先来确定示例程序的结构。在这里,我们只写出了程序中实际负责读取处理的回调部分。
我们将回调函数命名为 callback,它的参数用于读取的文件描述符(int fd)和注册回调函数时指定的指针(void *data)(图 2)。关于输出,我们再设置一个 output 函数。
int callback(int fd, void *data) { .... /* 返回值成功为1 */ /* 到达EOF为0 */ /* 失败为-1 */ } void output(int fd, const char *p, int len) { .... }图 2 回调函数与输出函数
在实际的程序中,需要在事件循环内使用选择可读写文件描述符的“select 系统调用”和“epoll”等,对文件描述符进行监视,并对数据到达的文件描述符调用相应的回调函数。
我们先来看看只使用 read 系统调用的实现方法。对了,所谓 read(2),是 UNIX 中广泛使用的一种记法,代表“手册显示命令 man 的第 2 节中的 read”的意思。由于第 2 节是系统调用,因此可以认为 read(2) 相当于“read 系统调用”的缩写。
只使用 read(2) 构建的回调函数如图 3 所示。
void callback(int fd, void *data) { char buf[BUFSIZ]; int n; n = read(fd, buf, BUFSIZ); if (n < 0) return -1; /* 失败 */ if (n == 0) return 0; /* EOF */ output(fd, buf, n); /* 写入 */ return 1; /* 成功 */ }图 3 用 read(2) 实现的输入操作(ver.1)
程序非常简单。当这个回调函数被调用时,显然输入数据已经到达了,因此只要调用 read 系统调用,将积累在输入缓冲区中的数据复制到 buf 中即可。当输入数据到达时,read 系统调用不会发生阻塞。
read 系统调用的功能是:①失败时返回负数;②到达 EOF 时返回 0;③读取成功时返回读取的数据长度。只要明白了这些,就很容易理解图 2 中程序的行为了吧。小菜一碟。
不过,这样简单的实现版本中必然隐藏着问题,你发现了吗?这个回调函数正确工作的前提是,输入数据的长度要小于 BUFSIZ(C 语言标准 IO 库中定义的一个常量,值貌似是 8192)。
但是,在通信中所使用的数据长度一般都不是固定的,某些情况下需要读取的数据长度可能会超过 BUFSIZ。于是,能够支持读取长度超过 BUFSIZ 数据的版本如图 4 所示。
void callback(int fd, void *data) { char buf[BUFSIZ]; int n; for (;;) { /* n = read(fd, buf, BUFSIZ); if (n < 0) return -1; /* 失败 */ if (n == 0) return 0; /* EOF */ output(fd, buf, n); /* 写入 */ if (n < BUFSIZ) break; /* 读取完毕,退出 */ } return 1; /* 成功 */ }图 4 用 read(2) 实现的输入操作(ver.2)
在版本 2 中,当读取到的数据长度小于 BUFSIZ 时,也就是当输入缓冲区中的数据已经全部读取出来的时候,程序结束。当读取到的数据长度等于 BUFSIZ 时,则表示缓冲区中还可能有残留的数据,因而可通过反复循环,直到读取完毕为止。
问题都解决了吗?还没有,事情可没那么简单。当输入的数据长度正好等于 BUFSIZ 时,这个程序会发生阻塞。我们说过,避免阻塞对于回调函数来说是非常重要的,因此这个程序还无法实际使用,我们还需要进行一些改进。
边沿触发与电平触发
好了,接下来我要宣布一件重要的事。我们刚才说图 3 的程序只能支持读取长度小于 BUFSIZ 的数据,但其实只要将读取的数据直接输出,它还是可以正常工作的,而且不会发生阻塞。不过,要实现这一点,负责事件监视的部分需要满足一定的条件。
在事件监视中,对事件的检测方法有两种,即边沿触发(edge trigger)和电平触发(level trigger)。这两个词原本是用在机械控制领域中的,边沿触发是指只在状态变化的瞬间发出通知,而电平触发是指在状态发生变化的整个过程中都持续发出通知(图 5)。
图 5 边沿触发与电平触发
select 系统调用属于电平触发,epoll 默认也是电平触发,但 epoll 也可以通过显式设置来实现边沿触发。
具体来说,是在 epoll_event 结构体的 events 字段通过 EPOLLET 标志来进行设置的。
要让图 3 的程序在不阻塞的状态下工作,事件监视就必须采用电平触发的方式。也就是说,在调用回调函数执行输入操作之后,如果读取缓冲区中还有残留的数据,在电平触发的方式下,就会再次调用回调函数来进行读取操作。
那么,采用电平触发就足够了吗?边沿触发的存在还有什么意义呢?由于边沿触发只在数据到达的瞬间产生事件,因此总体来看事件发生的次数会比较少,这也就意味着回调函数的启动次数也会比较少,可以提高效率。
使用 read(2) + select 的方法
刚才已经讲过,图 3 版本的程序,会将输入缓冲区中积累的数据全部读取出来,而当输入缓冲区为空时,调用 read 系统调用就会发生阻塞。为了避免这个问题,需要在调用 read 之前检查输入缓冲区是否为空。
下面,我们来创建一个 checking_read 函数。它先调用 read 系统调用,然后通过 select 系统调用检查输入缓冲区中是否有数据(图 6)。为了判断是否有剩余数据,checking_read 比 read 增加了一个参数。调用 checking_read 来代替 read,如果参数 cont 的值为真,就表示输入缓冲区中还有剩余的数据。
#include <sys/time.h> #include <sys/types.h> int checking_read(int fd, char *buf, int len, int *cont) { int n; *cont = 0; /* 初始化 */ n = read(fd, buf, len); /* 调用read(2) */ if (n > 0) { /* 读取成功 */ fd_set fds; struct timeval tv; int c; FD_ZERO(&fds); /* 准备调用select(2) */ FD_SET(fd, &fds); tv.tv_sec = 0; /* 不会阻塞 */ tv.tv_usec = 0; c = select(fd+1, &fds, NULL, NULL, &tv); if (c == 1) { /* 返回值为1=缓冲区不为空 */ *cont = 1; /* 设置继续标志 */ } } return n; } void callback(int fd, void *data) { char buf[BUFSIZ]; int n, cont; for (;;) { /* n = checking_read(fd, buf, BUFSIZ, &cont); if (n < 0) return -1; /* 失败 */ if (n == 0) return 0; /* EOF */ output(fd, buf, n); /* 写入 */ if (!cont) continue; /* 读取完毕,退出 */ } return 1; /* 成功 */ }图 6 用 read(2)实现的输入操作(ver.3)
用这种方法,在边沿触发的方式下也可以正常工作。边沿触发的好处就是能够减少事件发生的次数,但相对地,select 系统调用的调用次数却增加了。此外,在每次调用 read 系统调用时,还要问一下“还有剩下的数据吗”,总让人感觉怪怪的。
使用 read+O_NONBLOCK 标志
毕竟 read 系统调用可以直接接触输入缓冲区,那么理所当然地,在读取数据之后它应该知道缓冲区中是否还有剩余的内容。那么,能不能实现“调用 read,当会发生阻塞时通知我一下”这样的功能呢?
当然可以。只要在文件描述符中设置一个 O_NONBLOCK 标志,当输入输出中要发生阻塞时,系统调用就会产生一个“继续执行的话会发生阻塞”的错误消息提示,这个功能在 UNIX 系操作系统中是具备的。使用 O_NONBLOCK 的版本如图 7 所示。
#include <fcntl.h> #include <errno.h> /* (a) 初始化程序的某个地方 */ inf fl; fl = fcntl(fd, F_GETFL); fcntl(fd, F_SETFL, fl|O_NONBLOCK); /* 到此为止 */ ---------------------------------------------------------------------------------- void callback(int fd, void *data) { char buf[BUFSIZ]; int n; for (;;) { /* n = read(fd, buf, BUFSIZ); if (n < 0) { if (errno == EAGAIN) { /* EAGAIN=缓冲区为空 */ return 1; /* 读取操作结束 */ } return -1; /* 失败 */ } if (n == 0) return 0; /* EOF */ output(fd, buf, n); /* 写入 */ } }图 7 用 read(2) 实现的输入操作(ver.4)
怎么样?由于这次我们能够从本来拥有信息的 read 直接收到通知,整体上看比图 6 的版本要简洁了许多。
这个功能仅在对文件描述符设置了 O_NONBLOCK 标志时才会有效,因此在对文件描述符进行初始化操作时,不要忘记使用图 7(a) 中的代码对标志进行设置。
这种方法效率高、代码简洁,可以说非常优秀,但有一点需要注意,那就是大多数输入输出程序在编写时都没有考虑到文件描述符设置了 O_NONBLOCK 标志的情况。
当设置了 O_NONBLOCK 标志的文件描述符有可能发生阻塞时,会返回一个错误,而不会发生实质上的阻塞。一般的输入输出程序都没有预想到这种行为,因此发生这样的错误就会被认为是读取失败,从而引发输入输出操作的整体失败。
使用 O_NONBLOCK 标志时,一定要注意文件描述符的使用。O_NONBLOCK 标志会继承给子进程,因此在使用 fork 的时候要尤其注意。以前曾经遇到过这样的 bug:①对标准输入设置了 O_NONBLOCK;②用 system 启动命令;③命令不支持 O_NONBLOCK,导致诡异的错误。那时,由于忘记了子进程会继承 O_NONBLOCK 标志这件事,结果花了大量的时间才找到错误的原因。
Ruby 的非阻塞 I/O
刚才我们对 C 语言中的非阻塞 I/O 进行了介绍,下面我们来简单介绍一下 Ruby 的非阻塞 I/O。
Ruby 从 1.8.7 版本开始提供这里介绍过的两个实现非阻塞 I/O 的方法,即 read_partial 和 read_nonblock。
read_partial 方法可以将当前输入缓冲区中的数据全部读取出来。read_partial 可以指定读取数据的最大长度,其使用方法是:
str = io.read_partial(1024)read_partial 基本上不会发生阻塞,但若输入缓冲区为空且没有读取到 EOF 时会发生阻塞。也就是说,仅在一开始原本就没有数据到达的情况下会发生阻塞。
换句话说,只要是通过事件所触发的回调中,使用 read_partial 是肯定不会发生阻塞的,因此 read_partial 在实现上相当于 read+select 的组合。
将图 6 的程序改用 Ruby 进行的实现如图 8 所示。不过,和图 6 的程序不同的是,当数据大于指定的最大长度时不会循环读取。在读取的数据长度等于最大长度时,如果循环调用 read_partial 就有可能会发生阻塞。这真是个难题。
def callback(io, data) input = io.read_partial(4096) output(io, input) end图 8 使用 read_partial 的示例
相对地,read_nonblock 则相当于 read+O_NONBLOCK 的组合。read_nonblock 会对 io 设置 O_NONBLOCK 并调用 read 系统调用。read_nonblock 在可能要发生阻塞时,会返回 IO::WaitReadable 模块所包含的异常。
read_nonblock 的参数和 read_partial 是相同的。我们将图 7 的程序用 read_nonblock 改写成 Ruby 程序(图 9)。和 C 语言的版本相比,Ruby 版显得更简洁,而且 read_nonblock 会自动设置 O_NONBLOCK 标志,因此不需要进行特别的初始化操作。
def callback(io, data) loop do begin input = io.read_nonblock(4096) output(io, input) rescue IO::WaitReadable # 缓冲区为空了,结束 return end end end图 9 使用 read_nonblock 的例子
使用 aio_read 的方法
POSIX 1提供了用于异步 I/O 的“aio_XXXX” 函数集(表 1)。例如,aio_read 用于以异步方式实现和 read 系统调用相同的功能。这里的 aio 就是异步 I/O(Asynchronous I/O)的缩写。
1 POSIX(Portable Operating System Interface X,可移植操作系统接口)是由电气电子工程师学会(IEEE)制定的一套在各种 UNIX 操作系统上 API 的相互关联标准,正式名称为 IEEE 1003。
aio 函数集的功能,是将通常情况下会发生阻塞的系统调用(read、write、fsync)在后台进行执行。这些函数只负责发出执行系统调用的请求,因此肯定不会发生阻塞。
表1 异步I/O函数
名 称
功 能
aio_read
异步read
aio_write
异步write
aio_fsync
异步fsync
aio_error
获取错误状态
aio_return
获取返回值
aio_cancel
请求取消
aio_suspend
请求等待
运用 aio_read 的最简单的示例程序如图 10 所示,它的功能非常简单:
· 打开文件
· 用 aio_read 发出读取请求
· 用 aio_suspend 等待执行结束
· 或者用 aio_error 检查执行结束
· 用 aio_return 获取返回值下面我们来看看程序的具体内容。
1 /* 异步I/O所需头文件 */ 2 #include <aio.h> 3 4 /* 其他头文件 */ 5 #include <unistd.h> 6 #include <string.h> 7 #include <stdio.h> 8 #include <errno.h> 9 10 int 11 main() 12 { 13 struct aiocb cb; 14 const struct aiocb *cblist[1]; 15 char buf[BUFSIZ]; 16 int fd, n; 17 18 /* 准备文件描述符 */ 19 fd = open("/tmp/a.c", O_RDONLY); 20 21 /* 初始化控制块结构体 */ 22 memset(&cb, 0, sizeof(struct aiocb)); /* 清空 */ 23 cb.aio_fildes = fd; /* 设置fd */ 24 cb.aio_buf = buf; /* 设置buf */ 25 cb.aio_nbytes = BUFSIZ; /* 设置buf长度 */ 26 27 n = aio_read(&cb); /* 请求 */ 28 if (n < 0) perror("aio_read"); /* 请求失败检查 */ 29 30 #if 1 31 /* 使用aio_suspend检查请求完成 */ 32 cblist[0] = &cb; 33 n = aio_suspend(cblist, 1, NULL); 34 #else 1 35 /* 使用aio_error也能检查执行完成情况 */ 36 /* 未完成时返回EINPROGRESS */ 37 while (aio_error(&cb) == EINPROGRESS) 38 printf("retry\n"); 39 #endif 40 41 /* 执行完成,取出系统调用的返回值 */ 42 n = aio_return(&cb); 43 if (n < 0) perror("aio_return"); 44 45 /* 读取的数据保存在aio_buf中 */ 46 printf("%d %d ---\n%.*s", n, cb.aio_nbytes, cb.aio_nbytes, cb.aio_buf); 47 return 0; 48 }图 10 异步 I/O 示例
第 19 行将文件 open 并准备文件描述符。不过,这只是一个例子,并没有什么意义,因为实际的异步 I/O 往往是以套接字为对象的。根据我查到的资料来看,像 HP-UX 等系统中,aio_read 甚至是只支持套接字的。
从第 22 行开始对作为 aio_read 等的参数使用的控制块(aiocb)结构体进行初始化操作。read 系统调用有 3 个参数:文件描述符、读取缓冲区、缓冲区长度,但 aio_read 则是将上述这些参数分别通过 aiocb 结构体的 aio_fildes、aio_buf、aio_nbytes 这 3 个成员来进行设置。aiocb 结构体中还有其他一些成员,保险起见我们用 memset 将它们都初始化为 0(第 22 行)。
随后,我们使用 aiocb 结构体,通过 aio_read 函数预约执行 read 系统调用(第 27 行)。aio_read 只是提交一个请求,而不会等待读取过程的结束。对实际读取到的数据所做的处理,是在读取结束之后才进行的。
在这里我们使用 aio_suspend 执行挂起,直到所提交的任意一个请求执行完毕位置(第 33 行)。不过话说回来,我们也就提交了一个请求而已。
对请求执行完毕的检查也可以使用 aio_error 来实现。使用提交请求的 aiocb 结构体来调用 aio_error 函数,如果请求未完成则返回 EINPROGRESS,成功完成则返回 0,发生其他错误则返回相应的 errno 值。在这里,有一段对预处理器标明不执行的代码(34 ~ 39 行),这段代码使用 aio_error 用循环来检查请求是否完成。这是一个忙循环(busy loop),会造成无谓的 CPU 消耗,因此在实际的代码中是不应该使用的。
读取请求完成之后,就该对读取到的数据进行处理了(42 ~ 46 行)。read 系统调用的返回值可以通过 aio_return 函数来获取。此外,读取到的数据会被保存到 aiocb 结构体的 aio_buf 成员所指向的数组中。
图 10 的程序中是使用 aio_suspend 和 aio_error 来检查请求是否完成的,其实异步 I/O 也提供了在读取完成时调用回调函数的功能。在回调函数的调用上,有信号和线程两种方式,下面(为了简单起见)我们来介绍使用线程进行回调的方式(图 11)。
1 /* 异步I/O所需头文件 */ 2 #include <aio.h> 3 4 /* 其他头文件 */ 5 #include <unistd.h> 6 #include <string.h> 7 #include <stdio.h> 8 #include <errno.h> 9 10 static void 11 read_done(sigval_t sigval) 12 { 13 struct aiocb *cb; 14 int n; 15 16 cb = (struct aiocb*)sigval.sival_ptr; 17 18 /* 检查请求的错误状态 */ 19 if (aio_error(cb) == 0) { 20 /* 获取已完成的系统调用的返回值 */ 21 n = aio_return(cb); 22 if (n < 0) perror("aio_return"); 23 24 /* 读取到的数据存放在aio_buf中 */ 25 printf("%d %d ---\n%.*s", n, cb->aio_nbytes, cb->aio_nbytes, cb->aio_buf); 26 27 /* 示例到此结束 */ 28 exit(0); 29 } 30 return; 31 } 32 33 int 34 main() 35 { 36 struct aiocb cb; 37 char buf[BUFSIZ]; 38 int fd, n; 39 40 /* 准备文件描述符 */ 41 fd = open("/tmp/a.c", O_RDONLY); 42 43 /* 初始化控制块结构体 */ 44 memset(&cb, 0, sizeof(struct aiocb)); /* 清空 */ 45 cb.aio_fildes = fd; /* 设置fd */ 46 cb.aio_buf = buf; /* 设置buf */ 47 cb.aio_nbytes = BUFSIZ; /* 设置buf长度 */ 48 cb.aio_sigevent.sigev_notify = SIGEV_THREAD; 49 cb.aio_sigevent.sigev_notify_function = read_done; 50 cb.aio_sigevent.sigev_value.sival_ptr = &cb; 51 52 n = aio_read(&cb); /* 请求 */ 53 if (n < 0) perror("aio_read"); /* 请求失败检查 */ 54 55 /* 停止线程,处理转交给回调函数 */ 56 select(0, NULL, NULL, NULL, NULL); 57 return 0; 58 }图 11 异步 I/O 示例(回调)
图 11 的程序和图 10 的程序基本上是相同的,不同点在于,回调函数的指定(48 ~ 50 行)、回调函数(10 ~ 31 行)以及叫处理转交给回调函数并停止线程的 select(第 56 行)。
由于这里我们只需要对回调函数的调用进行一次等待,因此在初始化完毕后用 select 进行等待,在回调函数中则使用 exit。实际的事件驱动服务器程序中,主程序应该是“接受客户端连接并注册回调函数”这样一个循环。此外,在回调函数的最后也不应该用 exit,而是通过 aio_read 再次提交 read 请求,用于再次读取数据。
如果用 SIGEV_THREAD 设置回调函数并调用 aio_read,从系统内部来看,实际上是用多个线程来实现异步 I/O。也就是说,回调函数是在独立于主线程的另一个单独的线程中执行的。因此,一旦使用了 SIGEV_THREAD,在回调函数中就不能调用非线程安全的函数。
只要是会修改全局状态的函数,都是非线程安全的。POSIX 标准库函数中也有很多是非线程安全的。如果在回调函数中直接或间接地调用了这些函数,就有可能引发意想不到的问题。
SIGEV_THREAD 是用线程来实现回调的,但并不是所有的输入处理都会使用独立的线程,因此不必担心线程数量会出现爆发性地增长。
最后,为了让大家对如何用异步 I/O 来编写事件驱动型程序有一个直观的印象,在图 12 中展示了一个使用了 aio_read 等手段实现的 echo 服务器的代码节选。通过上面的讲解,大家是否对不使用 libev 等手段的情况下,如何实现事件驱动编程有了一些了解呢?
/* 异步I/O所需头文件 */ #include <aio.h> /* 其他头文件 */ #include <stdlib.h> #include <stdio.h> #include <string.h> #include <sys/socket.h> #include <netinet/in.h> static void read_done(sigval_t sigval) { struct aiocb *cb; int n; cb = (struct aiocb*)sigval.sival_ptr; if (aio_error(cb) == 0) { /* 获取完成的系统调用的返回值 */ n = aio_return(cb); if (n == 0) { /* EOF */ printf("client %d gone\n", cb->aio_fildes); aio_cancel(cb->aio_fildes, cb); /* 取消提交的请求 */ close(cb->aio_fildes); /* 关闭fd */ free(cb); /* 释放cb结构体 */ return; } printf("client %d (%d)\n", cb->aio_fildes, n); /* 直接写回 */ /* 读取到的数据存放在aio_buf中 */ /* 严格来说write也可能阻塞,但这里我们先忽略这一点 */ write(cb->aio_fildes, (void*)cb->aio_buf, n); aio_read(cb); } else { /* 错误 */ perror("aio_return"); return; } return; } static void register_read(int fd) { struct aiocb *cb; char *buf; printf("client %d registered\n", fd); cb = malloc(sizeof(struct aiocb)); buf = malloc(BUFSIZ); /* 初始化控制块结构体 */ memset(cb, 0, sizeof(struct aiocb)); /* 清空 */ cb->aio_fildes = fd; /* 设置fd */ cb->aio_buf = buf; /* 设置buf */ cb->aio_nbytes = BUFSIZ; /* 设置buf长度 */ cb->aio_sigevent.sigev_notify = SIGEV_THREAD; cb->aio_sigevent.sigev_notify_function = read_done; cb->aio_sigevent.sigev_value.sival_ptr = cb; /* 提交请求 */ aio_read(cb); } int main() { struct sockaddr_in addr; int s = socket(PF_INET, SOCK_STREAM, 0); addr.sin_family = AF_INET; addr.sin_addr.s_addr = INADDR_ANY; addr.sin_port = htons(9989); if (bind(s, (struct sockaddr*)&addr, sizeof(addr)) < 0) { perror("bind"); exit(1); } listen(s, 5); for (;;) { /* 接受来自客户端套接字的连接 */ int c = accept(s, NULL, 0); if (c < 0) continue; /* 开始监听客户端套接字 */ register_read(c); } }图 12 使用 aio_read 的 echo 服务器(节选)
Linux 中也提供了 io_getevents 等其他的异步 I/O 函数,这些函数的性能应该说更好一些。不过,它们都是 Linux 专用的,而且其事件响应只能支持通常文件,使用起来制约比较大,因此本书中的介绍,还是以 POSIX 中定义的,在各种平台上都能够使用的 aio_read 为主。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论