CppGuide社区 CppGuide社区
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
  • C++语言面试问题集锦
  • 🔥交易系统开发岗位求职与面试指南 (opens new window)
  • 第1章 高频C++11重难点知识解析
  • 第2章 Linux GDB高级调试指南
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 高性能网络通信协议设计精要
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 后端服务重要模块设计探索
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 源码分析系列

    • leveldb源码分析
    • libevent源码分析
    • Memcached源码分析
    • TeamTalk源码分析
    • 优质源码分享 (opens new window)
    • 🔥远程控制软件gh0st源码分析
  • 从零手写C++项目系列

    • 🔥C++游戏编程入门(零基础学C++)
    • 🔥使用C++17从零开发一个调试器 (opens new window)
    • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
    • 🔥使用C++从零写一个C语言编译器 (opens new window)
    • 🔥从零用C语言写一个Redis
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • Go语言特性

    • Go系统接口编程
    • 高效Go并发编程
    • Go性能调优
    • Go项目架构设计
  • Go项目实战

    • 🔥使用Go从零开发一个数据库
    • 🔥使用Go从零开发一个编译器 (opens new window)
    • 🔥使用Go从零开发一个解释器 (opens new window)
    • 🔥使用Go从零开发一个解释器 (opens new window)
    • 🔥用Go从零写一个编排器(类Kubernetes) (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
  • C++语言面试问题集锦
  • 🔥交易系统开发岗位求职与面试指南 (opens new window)
  • 第1章 高频C++11重难点知识解析
  • 第2章 Linux GDB高级调试指南
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 高性能网络通信协议设计精要
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 后端服务重要模块设计探索
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 源码分析系列

    • leveldb源码分析
    • libevent源码分析
    • Memcached源码分析
    • TeamTalk源码分析
    • 优质源码分享 (opens new window)
    • 🔥远程控制软件gh0st源码分析
  • 从零手写C++项目系列

    • 🔥C++游戏编程入门(零基础学C++)
    • 🔥使用C++17从零开发一个调试器 (opens new window)
    • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
    • 🔥使用C++从零写一个C语言编译器 (opens new window)
    • 🔥从零用C语言写一个Redis
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • Go语言特性

    • Go系统接口编程
    • 高效Go并发编程
    • Go性能调优
    • Go项目架构设计
  • Go项目实战

    • 🔥使用Go从零开发一个数据库
    • 🔥使用Go从零开发一个编译器 (opens new window)
    • 🔥使用Go从零开发一个解释器 (opens new window)
    • 🔥使用Go从零开发一个解释器 (opens new window)
    • 🔥用Go从零写一个编排器(类Kubernetes) (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
  • 第1章高频C++11重难点知识解析

  • 第2章Linux GDB高级调试指南

  • 第3章C++多线程编程从入门到进阶

  • 第4章C++网络编程重难点解析

  • 第5章网络通信故障排查常用命令

  • 第6章高性能网络通信协议设计精要

  • 第7章高性能服务结构设计

    • 7.1 网络通信组件的效率问题
    • 7.2 最原始的服务器结构
    • 7.3 一个连接一个线程模型
    • 7.4 Reactor 模式
    • 7.5 one thread one loop 思想
    • 7.6 收数据与发数据的正确姿势
      • 7.7 发送/接收缓冲区设计要点
      • 7.8 网络库的分层设计
      • 7.9 后端服务中的定时器设计
      • 7.10 业务数据处理一定要单独开线程吗
      • 7.11 侵入式程序结构与非侵入式程序结构
      • 7.12 带有网络通信模块的服务器的经典结构
    • 第8章Redis 网络通信模块源码分析

    • 第9章后端服务重要模块设计探索

    • C++后端开发进阶
    • 第7章高性能服务结构设计
    zhangxf
    2023-04-05
    目录

    7.6 收数据与发数据的正确姿势

    在网络通信中,我们可能既要通过 socket 去发送数据也要通过 socket 来收取数据。那么一般的网络通信框架是如何收发数据的呢?注意,这里讨论的范围是基于各种 IO 复用函数(select、poll、epoll 等)来判断 socket 读写来收发数据,其他情形比较简单,这里就不提了。

    我们这里以服务器端为例。服务器端接受客户端连接后,产生一个与客户端连接对应的 socket(Linux 下也叫 fd,为了叙述方便,以后称之为 clientfd),我们可以通过这个 clientfd 收取从客户端发来的数据,也可以通过这个 clientfd 将数据发往客户端。但是收与发在操作流程上是有明显的区别的。

    # 7.6.1 收数据的正确姿势

    对于收数据,当接受连接成功得到 clientfd 后,我们会将该 clientfd 绑定到相应的 IO 复用函数上并监听其可读事件。不同的 IO 复用函数可读事件标志不一样,例如对于 poll 模型,可读标志是 POLLIN,对于 epoll 模型,可读事件标志是 EPOLLIN,这在前面章节已经介绍过了,这里不再赘述。当可读事件触发后,我们调用 recv 函数从 clientfd 上收取数据(这里不考虑出错的情况),根据不同的网络模式我们可能会收取部分,或一次性收完。收取到的数据我们会放入接收缓冲区内,然后做解包操作。这就是收数据的全部“姿势”。对于使用 epoll 的 LT 模式(水平触发模式),我们每次可以只收取部分数据;但是对于 ET 模式(边缘触发模式),我们必须将本次收到的数据全部收完。

    ET 模式收完的标志是 recv 或者 read 函数的返回值是 -1,错误码是 EWOULDBLOCK,针对 Windows 和 Linux 下区别,前面章节已经详细地说过了。

    这就是读数据的全部姿势。流程图如下:

    # 7.6.2 发数据的正确姿势

    对于发数据,除了 epoll 模型的 ET 模式外,epoll 的 LT 模式或者其他 IO 复用函数,我们通常都不会去注册监听该 clientfd 的可写事件。这是因为,只要对端正常收数据,一般不会出现 TCP 窗口太小导致 send 或 write 函数无法写的问题。因此大多数情况下,clientfd 都是可写的,如果注册了可写事件,会导致一直触发可写事件,而此时不一定有数据需要发送。故而,如果有数据要发送一般都是调用 send 或者 write 函数直接发送,如果发送过程中, send 函数返回 -1,并且错误码是 EWOULDBLOCK 表明由于 TCP 窗口太小数据已经无法写入时,而仍然还剩下部分数据未发送,此时我们才注册监听可写事件,并将剩余的数据存入自定义的发送缓冲区中,等可写事件触发后再接着将发送缓冲区中剩余的数据发送出去,如果仍然有部分数据不能发出去,继续注册可写事件,当已经无数据需要发送时应该立即移除对可写事件的监听。这是目前主流网络库的做法。

    流程图如下:

    上述逻辑示例如下:

    直接尝试发送消息处理逻辑:

    /**
     *@param data 待发送的数据
     *@param len  待发送数据长度
     */
    void TcpConnection::sendMessage(const void* data, size_t len)
    {    
        int32_t nwrote = 0;
        size_t remaining = len;
        bool faultError = false;
    	
        //当前未监听可写事件,且发送缓冲区中没有遗留数据
        if (!channel_->isWriting() && outputBuffer_.readableBytes() == 0)
        {
            //直接发送数据
    		nwrote = sockets::write(channel_->fd(), data, len);      
            if (nwrote >= 0)
            {
                remaining = len - nwrote;           
            }
            else //nwrote < 0
            {
                nwrote = 0;
                //错误码不等于EWOULDBLOCK说明发送出错了
    			if (errno != EWOULDBLOCK)
                {
                    if (errno == EPIPE || errno == ECONNRESET)
                    {
                        faultError = true;
                    }
                }
            }
        }
    
    	//发送未出错且还有剩余字节未发出去
        if (!faultError && remaining > 0)
        {
            //将剩余部分加入发送缓冲区
            outputBuffer_.append(static_cast<const char*>(data) + nwrote, remaining);
            if (!channel_->isWriting())
            {
                //注册可写事件
    			channel_->enableWriting();
            }
        }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45

    不能全部发出去监听可写事件后,可写事件触发后处理逻辑:

    //可写事件触发后会调用handleWrite()函数
    void TcpConnection::handleWrite()
    {  
    	//将发送缓冲区中的数据发送出去
    	int32_t n = sockets::write(channel_->fd(), outputBuffer_.peek(), outputBuffer_.readableBytes());
    	if (n > 0)
    	{
    		//发送多少从发送缓冲区移除多少
    		outputBuffer_.retrieve(n);
    		//如果发送缓冲区中已经没有剩余,则移除监听可写事件
    		if (outputBuffer_.readableBytes() == 0)
    		{
    			//移除监听可写事件
    			channel_->disableWriting();
    		}
    	}
    	else
    	{
    		//发数据出错处理          
    		handleClose();
    	} 
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22

    对于 epoll LT 模式注册监听一次可写事件后,可写事件触发后,尝试发送数据,如果数据此时还不能全部发送完,不用再次注册可写事件;如果是 epoll 的 ET 模式,注册监听可写事件后,可写事件触发后,尝试发送数据,如果数据此时还不能全部发送完,需要再次注册可写事件以便让可写事件下次再次触发(给予再次发数据的机会)。当然,这只是理论上的情况,实际开发中,如果一段数据反复发送都不能完全发送完(例如对端先不收,后面每隔很长时间再收一个字节),我们可以设置一个最大发送次数或最大发送总时间,超过这些限定,我们可以认为对端出了问题,应该立即清空发送缓冲区并关闭连接,我们将在7.7.3节详细探讨这个问题。

    本节的标题是“收发数据的正确姿势”,其实还可以换一种说法,即“检测网络事件的正确姿势”,这里意指检测一个 fd 的读写事件的区别(对于侦听 fd,只检测可读事件):

    • 在 select、poll 和 epoll 的 LT 模式下,可以直接设置检测 fd 的可读事件;
    • 在 select、poll 和 epoll 的 LT 模式下不要直接设置检测 fd 的可写事件,应该先尝试发送数据,因为 TCP 窗口太小发不出去再设置检测 fd 的可写事件,一旦数据发出去应立即取消对可写事件的检测。
    • 在 epoll 的 ET 模式下,需要发送数据时,每次都要设置检测可写事件。

    # 7.6.3 不要多个线程同时在一个 socket 上 send 或 recv 数据

    由于 TCP 通信是全双工的,也就是说收数据和发数据是独立的,一般不会相互影响,这里建议不要多个线程同时在一个 socket 上 send 或 recv 数据指的不是说收发数据必须放在同一个线程里面进行,相反,实际开发中不少应用对于同一个 socket recv 数据使用一个线程,send 数据使用另外一个线程,但是需要额外做一点点在收或者发出错时对 socket 出错状态进行一下同步。

    但是单独的 send 或 recv 的操作,一定不要出现多个线程同时使用一个 socket 去 send 数据,或者多个线程同时使用一个 socket recv 数据。

    由于 TCP 数据是有序的,以 send 数据为例,如果多个线程同时使用一个 socket 去 send,那么最终对端收到的数据顺序就无法保证了。例如现在三个线程分别要发送 A、B、C 三个数据块,对端期望收到的顺序是 ABC,但是由于发送端使用三个线程发送,对端收到的数据顺序就不一定是 ABC 了。除非你使用一定的线程同步策略让三个线程按 ABC 的顺序发送数据,如果涉及这样的逻辑还不如放在一个线程中去操作。

    同样的道理,如果 recv 数据时,多个线程同时调用 recv 函数,那么每个线程可能收到部分数据,那么最终按什么顺序来把这些数据还原成发送端的顺序呢?

    不仅是 socket,对于管道也是一样到道理,不建议多个线程同时在同一个管道上读写。

    那有读者可能会有疑问:那平常我们说的多线程上传或者下载文件不是多线程同时对一个文件内容做读写吗?多线程上传或下载文件的原理是将文件按一定的大小切割成不同的内容块,然后开启多个连接,每个线程操作一个连接对指定编号的文件内容块进行读写,最后各个线程都完工后,按内容编号将文件重新组织起来。这本质上也不是多个线程同时操作一个 socket,而是每个线程只操作属于自己的文件块。

    上次更新: 2025/04/01, 20:53:14
    7.5 one thread one loop 思想
    7.7 发送/接收缓冲区设计要点

    ← 7.5 one thread one loop 思想 7.7 发送/接收缓冲区设计要点→

    最近更新
    01
    第二章 关键字static及其不同用法
    03-27
    02
    第一章 auto与类型推导
    03-27
    03
    C++语言面试问题集锦 目录与说明
    03-27
    更多文章>
    Copyright © 2024-2025 沪ICP备2023015129号 张小方 版权所有
    • 跟随系统
    • 浅色模式
    • 深色模式
    • 阅读模式