boost库asio编程(上)
1. boost库
1.1 概述
网络编程是现代软件开发中无可替代的一环,无论是构建庞大的分布式系统还是小型的桌面应用,都离不开网络的支持。Boost.Asio起源于Boost库,是一款专为网络I/O、定时器、串行端口通信设计的库,提供了同步和异步的编程模型,用以简化网络和低级I/O的操作。它的设计初衷是提供一套简洁、一致且功能全面的接口,以满足开发者在多样化网络编程场景下的需求。
1.2 库的特性与优势
Boost.Asio是一款功能全面的库,其主要特性与优势如下:
异步编程模型:它通过异步操作和回调机制,允许程序在等待I/O操作完成时继续执行其他任务,从而提高了程序的效率和响应速度
多协议支持: 它支持TCP、UDP、SSL等多种协议,可以帮助开发者快速实现各种网络应用
跨平台兼容性: Boost.Asio可以运行在Windows、Linux、macOS等多个平台上,保证了代码的可移植性和可维护性
可扩展性: 开发者可以基于Boost.Asio轻松实现自定义协议和服务,实现特定的业务逻辑
高性能: Boost.Asio的设计充分考虑了性能因素,尤其在高并发环境下表现出色
2. 主要函数的创建
2.1 终端节点的创建
所谓终端节点就是用来通信的端对端的节点,可以通过ip地址和端口构造,其它节点可以连接这个终端节点做通信。
客户端构造终端节点:通过对端的ip和端口构造一个endpoint,用这个endpoint和其通信
1 | int client_end_point() { |
服务端构造终端端点:则只需根据本地地址绑定就可以生成endpoint
1 | int server_end_point() { |
2.2 创建socket
客户端创建socket分为4步,创建上下文iocontext、选择协议、生成socket、打开socket。
1 | //创建和配置一个 TCP 套接字 |
服务端创建socket,需要生成一个acceptor的socket,用来接收新的连接
1 | int create_acceptor_socket() { |
2.3 绑定acceptor
服务器要将其绑定到指定的断点,所有连接这个端点的连接都可以被接收到。
1 | int bind_accept_socket() { |
连接指定的端点,作为客户端可以连接服务器指定的端点进行连接
1 | int connect_to_end() { |
服务器接收连接,当有客户端连接时,服务器需要接收连接
1 | int accept_new_connection() { |
3. 同步读写
3.1 同步写write_some
boost::asio提供了几种同步写的api,write_some可以每次向指定的空间写入固定的字节数,如果写缓冲区满了,就只写这一部分,返回写入的字节数。
1 | void write_to_socket(asio::ip::tcp::socket& sock) { |
3.2 同步写send
write_some使用起来比较麻烦,需要多次调用,asio提供了send函数。send函数会一次性将buffer中的内容发送给对端,如果有部分字节因为发送缓冲区满无法发送,则阻塞等待,直到发送缓冲区可用,则继续发送完成。
1 | int send_data_by_send() { |
3.3 同步写write
类似send方法,asio还提供了一个write函数,可以一次性将所有数据发送给对端,如果发送缓冲区满了则阻塞,直到发送缓冲区可用,将数据发送完成。
1 | int send_data_by_write() { |
3.4 同步读read_some
同步读和同步写类似,提供了读取指定字节数的接口read_some,需要多次调用
1 | std::string read_from_socket(asio::ip::tcp::socket& sock) { |
3.5 同步读receive
可以一次性同步接收对方发送的数据
1 | int read_data_by_receive() { |
3.6 同步读read
可以一次性同步读取对方发送的数据
1 | int read_data_by_read() { |
4. 同步读写客户端和服务端
4.1 客户端设计
基本思路:根据服务器对端的ip和端口创建一个endpoint,然后创建socket连接这个endpoint,之后就可以用同步读写的方式发送和接收数据了。
1 | using namespace boost::asio::ip; |
4.2 服务端设计
server函数作用:根据服务器ip和端口创建服务器acceptor用来接收数据,用socket接收新的连接,然后为这个socket创建session
session函数作用:该函数为服务器处理客户端请求,每当我们获取客户端连接后就调用该函数,类似于回调函数
1 | typedef std::shared_ptr<tcp::socket>socket_ptr; //定义指向socket的智能指针类型重命名为socket_ptr |
4.3 同步读写的优劣
1.缺点:
- 同步读写的缺陷在于读写是阻塞的,如果客户端对端不发送数据服务器的read操作是阻塞的,这将导致服务器处于阻塞等待状态
- 可以通过开辟新的线程为新生成的连接处理读写,但是一个进程开辟的线程是有限的,约为2048个线程,在Linux环境可以通unlimit增加一个进程开辟的线程数,但是线程过多也会导致切换消耗的时间片较多
- 该服务器和客户端为应答式,实际场景为全双工通信模式,发送和接收要独立分开
- 该服务器和客户端未考虑粘包处理
2.优点:当客户端连接数不多,而且服务器并发性不高的场景,可以使用同步读写的方式,这样能简化编码难度
5. 异步读写
这里先封装一个Node信息结构体,用来管理要发送和接收的数据,该结构包含数据域首地址,数据的总长度,以及已经处理的长度(已读的长度或者已写的长度)
1 | class MsgNode { |
而Session类负责完成异步读和异步写的工作
1 | class Session |
5.1 异步写async_write_some
该方法可能会多次调用回调函数,比如说我们要求这个_total_len发送12个字节数据,那么调用的回调函数为什么一直返回5个字节勒?这个是因为我们要求它发多长,但是tcp它有一个发送缓冲区,跟我们用户缓冲区是不一致的,tcp的发送缓冲区实际上空闲的空间比我们要求发送的总长度要小,所以它实际发送的长度要比我们要求的少。
1 | void Session::WriteToSocket(const std::string& buf) { //参数是要发送的数据 |
5.2 异步写async_send
socket里面还有另一个异步发送的函数async_send,它是boost::asio帮我们封装的,它是把数据全部发送完毕,才会调用回调函数。表面上是只做了一次回调,但它底层会多次调用async_write_some函数来帮我们完成数据的读取。
1 | void Session::WriteAllToSocket(const std::string& buf) { //参数是要发送的数据 |
5.3 异步读async_read_some
1 | void Session::ReadFromSocket() { |
5.4 异步读async_receive
1 | void Session::ReadAllFromSocket() { |
注意:读和写都是一样,发送数据推荐async_send,读数据推荐async_read_some。总结如下:
async_receive和async_read_some不能混着用,因为async_receive内部也是多次调用async_read_some,那么它也会多次触发
ReadCallBack回调函数,也会多次去发送我们要发送的数据。如果混着用,在boost::asio底层就不会去考虑顺序性,读到的数据就可能是乱的了。
6. 异步服务端实现
6.1 头文件
Session类是用于处理与客户端通信的工作;Server类是用于处理与客户端建立连接的任务。
1 | class Session |
6.2 主要函数
当有客户端发数据过来的时候,因为这里通过async_read_some监听了读事件,当读事件就绪的时候,会触发读的回调函数,在start函数里,为session绑定了一个读事件,_socket.async_read_some在boost::asio底层就会把这个_socket的读事件添加到epoll表里,这样当_socke有读事件就绪的时候(_socket它的tcp缓冲区由空变成有数据),就会触发回调函数handle_read,在回调函数里就可以把数据读出来。
至于为什么data会自动把新的数据拷贝到data里?因为asio帮我们做的,就是说我们把_data传给异步读函数async_read_some的时候,asio自动的把数据读到了_data里,所以handle_read函数里就直接读出_data的数据即可。
1 | int main() |
上面需要了解的是:用户态跟tcp的缓冲区的联系,什么能造成写就绪事件,什么能造成读就绪事件,也就是什么时候能触发写回调,什么时候能触发读回调。触发读回调是因为tcp的缓冲区有数据;触发写回调是因为tcp缓冲区有空闲空间,那么就可用从用户态将信息拷贝到tcp缓冲区,然后tcp再发出去,这时候就能触发写回调。
6.3 伪闭包延长连接生命周期
上面异步代码在某些情况下,会出现一些隐患,比如说这个服务器将要发送数据给客户端,也就是读到数据后,将要调用async_write去发数据,如果这个时候客户端断掉了,而写事件已经就绪,写事件在写的时候,会触发写回调handle_write,它也会发现对端断掉了,所以它在handle_write函数里面会执行错误处理代码段,因此就会回收数据(delete this)。但是因为对端关闭了,它还会触发一次读回调,而读回调也会捕获到对端关闭了,那么它也会去执行delete this代码。所以这样的话,两次的delete this就会造成内存的二次释放,系统就会崩溃。
上面的情况可以理解为:读到数据过后,在发送数据给对端之前,把连接断掉。
解决办法:因为c++里面没有闭包的机制,所以这里就用c++11的特性里面的智能指针。因为智能指针是有引用计数的,如果把智能指针传给一个函数对象,这个函数对象不被释放掉,那么这个智能指针就不会被释放掉。如果我们把智能指针传给回调函数(假设它会被放到一个回调的队列里),那么回调函数就是一个函数对象,这个函数对象没有被调用,没有被释放之前,智能指针也不会被释放。所以我们可以把智能指针作为参数传递给回调函数,函数内部再使用智能指针,智能指针就不会被释放掉了。
头文件代码:
1 | class Session:public std::enable_shared_from_this<Session> //继承一个模板类,需要什么样的类型,传入什么 |
实现功能的代码:
1 | //传入参数是ioc和端口号,往这个端口去连接,都会被_acceptor捕获 |
伪闭包延长连接声明周期原理:把智能指针作为参数传递给bind函数,bind是按值的方式去绑定的,它绑定一个智能指针的值给新生成的一个函数对象,新生成的函数对象会使用这个智能指针(以值的方式来使用),也就增加了智能指针的引用计数。我们使用的智能指针引用计数不为0,所以智能指针就不会被释放,则session就不会被释放(智能指针是session类型的),所以说该智能指针session它的声明周期就和新生成的函数对象的声明周期延长了,延长到和这个新生成的函数对象声明周期一致。因为我们既绑定了读的回调,也绑定了写的回调,所以说这个session,它的引用计数在这里每绑定一次,就会加1。也就不会出现新生成的函数对象被调用之前,这个session就被释放掉的情况(避免了释放已经释放的内存)。
6.4 添加发送队列
上面介绍了通过智能指针实现伪闭包的方式延长了session的生命周期,而实际使用的服务器并不是应答式,而是全双工通信方式,服务器是一直监听写事件,接收对端数据的,那么当服务器发送数据给对端时,就不能保证数据的有序性。
解决办法:设计一个数据结点,首先在CSession类里新增一个队列存储要发送的数据,因为我们不能保证每次调用发送接口的时候上一次数据已经发送完(如果上一次没有发送完,而恰巧我们又调用了一次接口,那么boost.asio底层不知道是要发送上一次没有发送完的数据,还是发送新一次调用的数据。所以勒,这样就能造成数据的混乱),所以就要把要发送的数据放入队列中,通过回调函数不断地发送。而且我们不能保证发送的接口和回调函数的接口在一个线程,所以要增加一个锁保证发送队列安全性。
头文件代码:
1 | class MsgNode |
实现功能的代码:
1 | CServer::CServer(boost::asio::io_context& io_context, short port):_io_context(io_context), _port(port), |
6.5 粘包处理
1.粘包问题
粘包问题是服务器收发数据常遇到的一个现象,比如说当客户端发送两个Hello World!给服务器,服务器TCP接收缓冲区接收了两次,一次是Hello World!Hello, 第二次是World!
2.粘包原因
因为TCP底层通信是面向字节流的,TCP只保证发送数据的准确性和顺序性,字节流以字节为单位,客户端每次发送N个字节给服务端,N取决于当前客户端的发送缓冲区是否有数据,比如发送缓冲区总大小为10个字节,当前有5个字节数据(上次要发送的数据比如’loveu’)未发送完,那么此时只有5个字节空闲空间,我们调用发送接口发送hello world!其实就是只能发送Hello给服务器,那么服务器一次性读取到的数据就很可能是loveuhello。而剩余的world!只能留给下一次发送,下一次服务器接收到的就是world!
3.产生粘包问题其它原因
- 客户端的发送频率远高于服务器的接收频率,就会导致数据在服务器的tcp接收缓冲区滞留形成粘连,比如客户端1s内连续发送了两个hello world!,服务器过了2s才接收数据,那一次性读出两个hello world!
- tcp底层的安全和效率机制不允许字节数特别少的小包发送频率过高,tcp会在底层累计数据长度到一定大小才一起发送,比如连续发送1字节的数据要累计到多个字节才发送,可以了解下tcp底层的Nagle算法
- 再就是我们提到的最简单的情况,发送端缓冲区有上次未发送完的数据或者接收端的缓冲区里有未取出的数据导致数据粘连
解决办法:处理粘包的方式主要采用应用层定义收发包格式的方式,这个过程俗称切包处理,常用的协议被称为tlv协议(消息id+消息长度+消息内容)
头文件代码:
1 | class MsgNode //存放数据的节点 |
实现功能的函数:
1 | //主函数传入ioc和监听的端口号,该函数进行初始化成员变量和建立了服务端的终端端点(开始监听连接) |