**Linux 知识补充 -- Socket 编程 - TCP 非阻塞方式 - 基本知识** 1652270 **冯舜** 目录结构 ================= !![dir,目录结构] TCP 非阻塞方式的客户端/服务端 ================ ## 测试程序 `tcp_server1-1` 和 `tcp_client1-1` 写测试程序服务端 `tcp_server1-1.c` 和 `tcp_client1-1.c`, 分别在 `accept()` 后和 `connect()` 后将 socket 连接用 `fcntl()` 函数设置为非阻塞方式, 建立连接后调用 `read()` 后退出. 运行结果: !![1-1,非阻塞服务端] !![1-2,非阻塞客户端] 发现两侧建立连接后马上退出, `read()` 函数返回值皆为 -1, 打印错误信息为: Resource temporarily unavailable. ## 使用 `select()` 实现阻塞的测试程序 `tcp_server1-2` 和 `tcp_client1-2` 写测试程序服务端 `tcp_server1-2.c` 和 `tcp_client1-2.c`, 连接建立后 socket 设置为非阻塞方式, 建立连接后, 先调用 `select()` 阻塞直到 socket 可读再返回, 再调用 `read()` 后退出. 运行结果: !![1-3,非阻塞 socket, 使用 `select()` 的服务端] !![1-4,非阻塞 socket, 使用 `select()` 的客户端] 发现两侧在调用 `select()` 后都进入阻塞状态, 不再打印错误信息, 不再立即返回退出. ## 建立 socket 后马上设置为非阻塞方式, 正常完成连接, 停留在读阻塞 写测试程序服务端 `tcp_server1-3.c` 和 `tcp_client1-3.c`, 建立 socket 后马上设置为非阻塞方式(代码中可以用建立 socket 时给出 `SOCK_NONBLOCK` 选项来做到这一点, 也可在建立后马上使用 `fnctl()` 函数来做到), 尝试建立连接并进入读阻塞. 此时, `accept()` 前要用 `select()` 等待服务端 socket 可读, `connect()` 后要用 `select()` 等待客户端 socket 可写. 运行结果: !![1-5,非阻塞 socket, 建立 socket 后马上非阻塞的服务端] !![1-6,非阻塞 socket, 建立 socket 后马上非阻塞的客户端] 发现两侧在两次正确调用 `select()` 后, 成功建立连接, 进入阻塞状态, 不再打印错误信息, 不再立即返回退出. TCP 非阻塞收发数据 (单侧) ========================= ## 客户端发, 服务端收 写测试程序服务端 `tcp_server2-1.c` 和客户端 `tcp_client2-1.c`, 用 Section 2.3 的方法来设置非阻塞. 连接成功后, client 每隔 1 秒发 10 字节数据, server 每隔 1 秒收充分大量的数据, 以大小为 100 (+1 存 null-terminator) 的缓冲区存储. 运行结果: !![2-1,非阻塞 socket, 收数据的服务端和发数据的客户都按] ### 在 client 端按 Ctrl-C 终止程序, 另一端的反应 如图, 客户端(右) Ctrl-C, 服务端(左)仍在持续死循环读取, 每次读到 0 字节. 说明, 作为读方的服务端没有意识到作为写方的客户端的连接中断. !![2-cint,客户端(右) Ctrl-C, 服务端(左)仍在持续死循环读取, 每次读到 0 字节] ### 在 server 端按 Ctrl-C 终止程序, 另一端的反应 如图, 服务端(左) Ctrl-C, 客户端(右) 仍然成功地写一次, 但在第二次写时, 遭遇到 SIGPIPE 信号从而终止. 说明, 作为写方的客户端意识到了作为读方的服务端的连接中断. !![2-sint,服务端(左) Ctrl-C, 客户端(右) 遭遇到 SIGPIPE 终止] ### 在 client 端用 `kill -9` 终止程序, 另一端的反应 如图, 客户端(右) 被 `kill -9` 杀死, 服务端(左)仍在持续死循环读取, 每次读到 0 字节. 说明, 作为读方的服务端没有意识到作为写方的客户端的连接中断. !![2-ckill,客户端(右) 被 `kill -9` 杀死, 服务端(左)仍在持续死循环读取, 每次读到 0 字节] ### 在 server 端用 `kill -9` 终止程序, 另一端的反应 如图, 服务端(左) 被 `kill -9` 杀死, 客户端(右) 仍然成功地写一次, 但在第二次写时, 遭遇到 SIGPIPE 信号从而终止. 说明, 作为写方的客户端意识到了作为读方的服务端的连接中断. !![2-skill,服务端(左) 被 `kill -9` 杀死, 客户端(右) 遭遇到 SIGPIPE 终止] ## 服务端端发, 客户端收 写测试程序服务端 `tcp_server2-2.c` 和客户端`tcp_client2-2.c`, 用 Section 2.3 的方法来设置非阻塞. 连接成功后, server 每隔 1 秒发 10 字节数据, client 每隔 1 秒收充分大量的数据, 以大小为 100 (+1 存 null-terminator) 的缓冲区存储. 运行结果: !![2-3,非阻塞 socket, 发数据的服务端] !![2-4,非阻塞 socket, 收数据的客户端] ### 在 client 端按 Ctrl-C 终止程序, 另一端的反应 如图, 客户端(右) Ctrl-C, 服务端(左) 仍然成功地写两次, 但在第三次写时, 遭遇到 SIGPIPE 信号从而终止. 说明, 作为写方的服务端意识到了作为读方的客户端的连接中断. !![2-2-cint,客户端(右) Ctrl-C, 服务端(左) 遭遇到 SIGPIPE 终止] ### 在 server 端按 Ctrl-C 终止程序, 另一端的反应 如图, 服务端(左) Ctrl-C, 客户端(右)仍在持续死循环读取, 每次读到 0 字节. 说明, 作为读方的客户端意识不到作为写方的服务端的连接中断. !![2-2-sint,服务端(左) Ctrl-C, 客户端(右)仍在持续死循环读取, 每次读到 0 字节] ### 在 client 端用 `kill -9` 终止程序, 另一端的反应 如图, 客户端(右) 被 `kill -9` 杀死, 服务端(左) 仍然成功地写一次, 但在第二次写时, 遭遇到 SIGPIPE 信号从而终止. 说明, 作为写方的服务端意识到了作为读方的客户端的连接中断. !![2-2-ckill,客户端(右) 被 `kill -9` 杀死, 服务端(左) 遭遇到 SIGPIPE 终止] ### 在 server 端用 `kill -9` 终止程序, 另一端的反应 如图, 服务端(左)被 `kill -9` 杀死, 客户端(右) 仍在持续死循环读取, 每次读到 0 字节. 说明, 作为读方的客户端意识不到作为写方的服务端的连接中断. !![2-2-skill,服务端(左)被 `kill -9` 杀死, 客户端(右) 仍在持续死循环读取, 每次读到 0 字节] TCP 非阻塞收发数据 (双侧并发收发, 不用子进程/线程) ========================= ## 使用 `select()` 函数超时的办法 (`tcp_*3-*a.c`) 写测试程序服务端 `tcp_server3-1a.c` 和客户端 `tcp_client3-1a.c`, 用 Section 2.3 的方法来设置非阻塞. 连接成功后, 双方并发收发数据, 其中当可读时立即读入 100 字节缓冲区内; 当可写时(系统缓冲区没占满时, 总是可写的) 每 $t$ 秒写 $m$ 字节. 对于 client, $t = 3, m = 15$; 对于 server, $t = 1, m = 10$. 使用 `select()` 函数中的计时功能, 以及每次返回时将计时器置为剩余的时间这一特性, 可以实现读写并发. 0. 初始化计时器为 $t$ s; 1. 每次将计时器传给 `select()`, 阻塞直到 socket 可读; 2. 当 `select()` 返回值指出 socket 可读时, 读; 3. 当 `select()` 返回值指出 socket 仍不可读, 但计时器时间到, 应写时, 写, 且重置计时器为 $t$ s. 4. 回到 1 , 重新开始循环. 运行结果: !![3-1,非阻塞 socket, 收发并发] ## 使用 `SIGALRM` 信号中断 `select()` 的办法 (其他代码) 在连接建立时以及每次写完成时, 用 `alarm(秒数)` 设立一个定时器, 以使得到时后本进程收到 `SIGALRM` 信号, 打断等待可读的 `select()` (有时信号到达时并未执行到 `select()`, 此时应在信号处理函数中将一全局标志变量置位, 死循环重新开始时判断该全局变量决定是否直接开始写), 以使得马上开始写的任务. 运行结果: !![extra2,非阻塞 socket, 收发并发] ## 要求服务端收数据时每次必须收到 88 字节 ### 方法1 将服务端的 `read()` 函数换为带有 `MSG_WAITALL` 选项的 `recv()` 函数, 然后在每次 `recv()` 的前后用 `ioctl(new_sockfd, FIONBIO, &(int){ 0 })` 和 `ioctl(new_sockfd, FIONBIO, &(int){ 1 })` 临时关闭和开启非阻塞模式即可. 如 `tcp_server3-2.c`. !![3-2,服务端在有可读字节时, 开始阻塞] !![3-3,服务端结束阻塞, `recv()` 返回值指示读了 88 字节] ### 方法2 使用 `setsockopt(new_sockfd, SOL_SOCKET, SO_RCVLOWAT, &(int) { 88 }, sizeof(int));` 将新接收的 socket 的 TCP 接收数据低水位值设置为 88 字节, 其他流程不变. 这样, 在调用 `select()` 时, 接收缓冲区必须有 88 字节及以上, 才会返回. 这便可以达到目的. 如 `tcp_server3-2-1.c`. !![extra3,服务端直到接收到 88 字节后再读出] ### 方法3 每次读前, 用 `ioctl(new_sockfd,FIONREAD,&bytes_available)` 之后再检查 `bytes_available` 的值的方式, 检查 `new_sockfd` 的可读字节数. 如不满 88 字节, 则不读, 重新开始循环; 否则, 读. 这样可以保证达到目的, 但会导致高 CPU 占用率. 这个方式没有用代码实现. TCP 非阻塞写入直到失败 ========================== ## 服务端连接成功后直接等待 写测试程序服务端 `tcp_server4-1.c` 和客户端 `tcp_client4-1.c`, 用 Section 2.3 的方法来设置非阻塞. 连接成功后, 服务端用 `getchar()` 进入等待状态, 客户端用 `write()` 连续快速大量写入, 直到写入失败. 控制客户端代码中的 `once` (一次写入的字节数) 可以控制写入的速度; 控制变量 `blockBeforeWrite` (为 0 则写前不调用 `select()` 阻塞, 为 1 则调用) 可以控制写前是否阻塞. 用两种写入速度以及是否阻塞形成四种情况, 观察写失败(或阻塞在 `select()`)后, netstat 观察到的队列中字节数情况. ### 客户端不阻塞快写入 (`once == 10`, `blockBeforeWrite == 0`) !![4-quicknbwrite,客户端不阻塞快写入] 图片内容: 总计写入 103582 = 服务端接收队列 103582 + 客户端发送队列 0 (若再增大 `once`, 仍满足 总计写入 = 服务端接收队列 + 客户端发送队列(==0), 且总计写入值增大至上限约 130000. 但这上限并不是绝对的, 有少数次这个值远大于 130000) ### 客户端不阻塞慢写入 (`once == 1`, `blockBeforeWrite == 0`) !![4-slownbwrite,客户端不阻塞慢写入] 图片内容: 总计写入 372689 = 服务端接收队列 242881 + 客户端发送队列 129808 ### 客户端阻塞快写入 (`once == 10`, `blockBeforeWrite == 1`) !![4-quickbwrite,客户端阻塞快写入] 图片内容: 总计写入 1027650 = 服务端接收队列 232526 + 客户端发送队列 795124 (若再增大 `once`, 这些值的大小已无明显变动) ### 客户端阻塞慢写入 (`once == 1`, `blockBeforeWrite == 1`) !![4-slowbwrite,客户端阻塞慢写入] 图片内容: 总计写入 303181 = 服务端接收队列 246129 + 客户端发送队列 57052 ### 结合了固定缓冲区的所有情况列表 将服务端和客户端的 socket 发送和接收缓冲设为定值 16384 和 87380 字节, 重做上面四个情况. 最终整理出的数据表格(实际数字跳跃很大, 故取大概平均值填表)如下: ("Cl/Sv" 分别表示 "客户端/服务端") 实验情况(固/非固定缓冲区,阻/非阻塞,快/慢)| ClSnd | SvRcv | Cl写字节计数 == ClSnd + SvRcv --------------------------------------|--------|--------|----------- 非固不阻快 | 0 | 103000 | 103000 非固不阻更快| 0 | 130000 | 130000 非固不阻慢 | 130000| 240000 | 370000 非固阻快 | 800000| 230000 | 10300000 非固阻更快 | 800000| 230000 | 10300000 非固阻慢 | 57000| 246000 | 303000 固不阻快 | 0 | 35000 | 35000 (跳动不大) 固不阻更快(100)| 0 | 44000 | 44000 (跳动不大) 固不阻更快(200)| 0(Cl写计数超过 60000 时, 此处大于 0) | 57000 | 57000 (跳动很大, 有时为超过 60000 的值) 固不阻慢 | 0 | 15000 | 15000 固阻快 | 9000 | 49000 | 58000 固阻更快(100)| 9500 | 52500 | 62000 固阻更快(200)| 9000 | 52000 | 61000(跳动较大) 固阻慢 | 9500 | 52500 | 62000 可知, 是否在 `write()` 前用 `select()` 阻塞, 以及每次写字节数的多少, 都会影响排队字节数和最终写的字节数. ## 服务端连接成功后每读 20 字节延时 1 秒 写测试程序服务端 `tcp_server4-2.c` 和客户端 `tcp_client4-2.c`, 连接成功后, 服务端每隔 1 秒读 20 字节, 客户端用 `write()` 连续快速大量写入, 直到写入失败. 在本例中, 固定了服务端和客户端的缓冲区大小为发送 16384 字节和接收 87380 字节. 则写入失败时, 客户端发送队列和服务端接收队列的字节数分别为 17376 和 50790, 和正好为已经写入的字节 68166. 其中, 服务端接收队列字节数随着服务端的读取, 每秒减去 20, 客户端发送队列字节数不变. 可见, 排队字节数基本符合缓冲区大小. !![5-1,写入失败时] 写入失败时, 连续尝试重新写入, 直到服务端读取到允许新的字节数进入队列, 就可以正常重新写入了. 在服务端接收了约 4420 字节后, 写入失败的状态解除. 此时, 客户端发送队列字节数仍然不变, 写入的字节数全部进入服务端的接收队列. !![5-2,写入失败解除前] !![5-3,写入失败解除后] 服务端多连接读/写/接收连接并发, 客户端多连接读/写并发 (不用子进程/线程) ==================================== ## 使用 `select()` 函数超时的办法 (`tcp_*5*a.c`) ### 服务端多连接读/写/接收连接并发 写测试程序服务端 `tcp_server5a.c`, 维护多个 accepted socket (以及它们的收数据缓冲区、写入计时器)构成的数组, 在 `select()` 时监听所有的 accepted socket 和 listening socket, 以便及时收数据、接收新连接, 在最近的写间隔计时器计时完毕时, 对对应的 socket 进行写入. 在写入前, 也要调用 `select()`, 若此时又有 socket 可读或可接受, 也要及时做相应处理. 客户端 `tcp_client5-1a.c` 与前面的 `tcp_client3-1a.c` 基本一致. 运行结果: !![6-1,一个服务端和两个客户端同时运行, 服务端和两客户端均正常通讯] !![6-2,服务端正确处理和两个客户端的并发通讯] !![6-3,服务端从两个客户端均接收到正确的内容] !![6-4,两个客户端均从服务端接收到正确的内容] ### 客户端同时维护两个 socket 连接的并发读写 写测试程序客户端 `tcp_client5-2a.c`, 维护两个连接 socket 的并发读写, 方法与服务端的类似, 只是少了"检测新连接" 的过程. 运行结果: !![6-5,两个服务端和一个客户端同时运行, 读写时序正常] !![6-6,两个服务端从客户端处接收到的内容正确] !![6-6,客户端从两个服务端处接收到的内容正确] ## 使用 `SIGALRM` 信号中断 `select()` 的办法 (其他代码) 其他同 "使用 `select()` 函数超时的办法 (`tcp_*5*a.c`)", 只不过定时写、随时读的机制换为了用信号 `SIGALRM` 实现. !![extra-5-1,一个服务端和两个客户端(5-1)同时运行, 服务端先和第一个客户端通讯] !![extra-5-2,一个服务端和两个客户端(5-1)同时运行, 服务端和两个客户端并发通讯] !![extra-5-3,一个服务端和两个客户端(5-1)同时运行, 服务端正确接收两个客户端的信息] !![extra-5-4,两个服务端和一个客户端(5-2)同时运行]