cpp-tbox项目链接 https://gitee.com/cpp-master/cpp-tbox
更多精彩内容欢迎关注微信公众号:码农练功房
往期精彩内容:
Linux应用框架cpp-tbox之弱定义
Linux应用框架cpp-tbox之日志系统设计
Linux应用框架cpp-tbox之事件驱动EventLoop
Linux应用框架cpp-tbox之事件驱动Event
Linux应用框架cpp-tbox之线程池
重要性
在Linux应用框架cpp-tbox之事件驱动EventLoop一文中,有一点没有明确指出:
EventLoop的IO线程是基于非阻塞IO模型+IO复用。
在这种编程模型下,我们要避免阻塞在read()/write()/accept()/connect或者其他IO系统调用上,只有这样才可以最大程度上复用IO线程。
这样一来,应用层缓冲是必需的。
以TCP通信为例,在数据接收时,考虑以下场景:发送方发送两条10k字节的消息,接收方接收到数据的情况可能是:
- 一次性收到20k数据
- 分两次收到,第一次5k,第二次15k
- 分三次收到,第一次6k,第二次8k,第三次6k
- 类似其他可能
有了接收缓冲后,我们可以把接收到的数据先放到缓冲中去。
在协议层根据事先定义的通信协议判断是否接收到了一条完整的消息,当收到一条完整的消息后,再通知应用层去处理。
同样地,在使用TCP发送数据发送时,考虑以下场景:应用层需要发送10k字节的数据,而在执行write时,由于操作系统维护的缓冲区空间不足,可能无法立即接纳所有待写入的数据,那剩下的数据该如何处理?一个常见但不高明的处理方式是:
s32 nRet = 0;
s32 nSentLen = 0; //已发送长度
s32 nRemainLen = dwMsgLen; //剩下未发送长度
u32 dwTimeOut = 10000;
u64 dwStartTime = GETTIME64();
while (nSentLen < dwMsgLen)
{
SLEEP(1);
nRet = send(dwHandle, pMsgBody + nSentLen, nRemainLen, nFlag);
if (SOCKET_ERROR == nRet || nRet > nRemainLen)
{
return 0;
}
nSentLen += nRet;
nRemainLen -= nRet;
}
但我们知道,上面的代码的效率并不高。
如果有了发送缓冲后,我们可以把操作系统内核暂时无法接受的数据先存入发送缓冲中,然后关注可写事件,一旦可写就立即从发送缓冲区中取出数据发送之。
如果还是没有写完那就继续关注可写事件,等待下次可写时,继续写入。当发送缓冲区中的所有数据写完时,立即停止关注可写事件(避免不断被触发busy loop)。
如果在发送缓冲区中的数据没有写完时,又来了新的数据,这部分数据则被追加到发送缓冲区中。
整体结构图
下图是cpp-tbox中应用层缓冲的代码模型:
Buffer类是对缓冲区的抽象。
BufferedFd是对应用层缓冲区的抽象,应用层缓冲包含接收缓冲和发送缓冲,即对应两个Buffer类的实例。
FdEvent是对IO事件的抽象,前文已介绍,不再赘述。
这里通过设置FdEvent的回调接口,在接收数据时,把IO线程接收到的数据通过回调函数写入接收缓冲。
在发送数据时,把不能一次性发送完的数据,在可写事件发生后,让IO线程通过回调函数读取发送缓冲区数据发送出去。
Buffer
下图是对Buffer的一个简略画法:
* buffer_ptr_ buffer_size_
* | |
* v V
* +----+----------------+----------------+
* | | readable bytes | writable bytes |
* +----+----------------+----------------+
* ^ ^
* | |
* read_index_ write_index_
Buffer类的内部是一块连续的内存,有两个索引,用来标识读写位置的偏移。由图可以得出以下公式(函数):
//可写空间大小
inline size_t writableSize() const { return buffer_size_ - write_index_; }
//可读空间大小
inline size_t readableSize() const { return write_index_ - read_index_; }
在数据写入、读取的时候,Buffer实例维护读写索引的值。
如果等待写入的数据,大于可写入的空间,应该如何处理?可以在Buffer::ensureWritableSize中找到答案:
bool Buffer::ensureWritableSize(size_t write_size)
{
if (write_size == 0)
return true;
//! 空间足够
if (writableSize() >= write_size)
return true;
//! 检查是否可以通过将数据往前挪是否可以满足空间要求
if ((writableSize() + read_index_) >= write_size) {
//! 将 readable 区的数据往前移
::memmove(buffer_ptr_, (buffer_ptr_ + read_index_), (write_index_ - read_index_));
write_index_ -= read_index_;
read_index_ = 0;
return true;
} else { //! 只有重新分配更多的空间才可以
size_t new_size = (write_index_ + write_size) << 1; //! 两倍扩展
uint8_t *p_buff = new uint8_t[new_size];
if (p_buff == nullptr)
return false;
if (buffer_ptr_ != nullptr) {
//! 只需要复制 readable 部分数据
::memcpy((p_buff + read_index_), (buffer_ptr_ + read_index_), (write_index_ - read_index_));
delete [] buffer_ptr_;
}
buffer_ptr_ = p_buff;
buffer_size_ = new_size;
return true;
}
}
这里主要分两种情况:
- 检查是否可以通过将数据往前挪满足空间要求,避免了多次动态分配内存。
- 只有重新分配更多的空间才可以写入。
BufferedFd
BufferedFd聚合了接收、发送缓冲,建立了缓冲区和具体文件描述符的关联。比较核心的是两个注册到FdEvent的回调函数:
if (events & kReadOnly) {
sp_read_event_ = wp_loop_->newFdEvent("BufferedFd::sp_read_event_");
sp_read_event_->initialize(fd_.get(), event::FdEvent::kReadEvent, event::Event::Mode::kPersist);
sp_read_event_->setCallback(std::bind(&BufferedFd::onReadCallback, this, _1));
}
if (events & kWriteOnly) {
sp_write_event_ = wp_loop_->newFdEvent("BufferedFd::sp_write_event_");
sp_write_event_->initialize(fd_.get(), event::FdEvent::kWriteEvent, event::Event::Mode::kPersist);
sp_write_event_->setCallback(std::bind(&BufferedFd::onWriteCallback, this, _1));
}
数据接收,涉及到的是BufferedFd::onReadCallback这个回调函数。在读数据时,这里用到的是readv接口,相比于传统的read接口,有几个显著的优点:
- 减少系统调用次数:readv允许一次性从文件描述符或socket中读取数据到多个缓冲区中。这与read接口形成对比,后者只能将数据读取到单个缓冲区中。当需要从一个文件或网络流中读取大量数据并分散到不同的内存位置时,使用readv可以避免多次调用read,从而减少系统调用的开销。
- 提高效率:由于系统调用是较为昂贵的操作,减少系统调用次数可以显著提升程序的执行效率,特别是在大量数据传输的场景下。通过一次readv调用完成多缓冲区的数据读取,减少了上下文切换和内核态与用户态之间的切换,提高了CPU的使用效率。
- 减少数据复制:在某些实现中,使用readv可以减少数据在内核空间和用户空间之间的复制操作。数据可以直接从内核缓冲区分散复制到用户态的多个缓冲区中,避免了先复制到单一缓冲区再分散到各自目标位置的过程。
- 灵活的数据处理:readv使得程序可以更灵活地处理数据,特别是在处理不同格式或结构化数据时。例如,可以将数据头、数据体分别读入不同的缓冲区,便于后续处理。
- 适合大文件或网络数据包处理:对于需要处理大文件或网络数据包(特别是需要解析不同类型的数据字段)的场景,readv能够更高效地组织数据读取,便于直接将数据分割到预定义的缓冲区中,减少处理步骤。
数据发送,涉及BufferedFd::send和BufferedFd::onWriteCallback这两个接口。BufferedFd::send的发送逻辑为:
bool BufferedFd::send(const void *data_ptr, size_t data_size)
{
if (sp_write_event_ == nullptr) {
LogWarn("send is disabled");
return false;
}
//! 如果当前没有 enable() 或者发送缓冲区中还有没有发送完成的数据
if ((state_ != State::kRunning) || (send_buff_.readableSize() > 0)) {
//! 则新的数据就直接放到发送缓冲区
send_buff_.append(data_ptr, data_size);
} else {
//! 否则尝试发送
ssize_t wsize = fd_.write(data_ptr, data_size);
if (wsize >= 0) { //! 如果发送正常
//! 如果没有发送完,还有剩余的数据
if (static_cast<size_t>(wsize) < data_size) {
//! 则将剩余的数据放入到缓冲区
const uint8_t* p_remain = static_cast<const uint8_t*>(data_ptr) + wsize;
send_buff_.append(p_remain, (data_size - wsize));
sp_write_event_->enable(); //! 等待可写事件
}
} else { //! 否则就是出了错
if (errno == EAGAIN) { //! 文件操作繁忙
send_buff_.append(data_ptr, data_size);
sp_write_event_->enable(); //! 等待可写事件
} else {
LogWarn("send fail, drop data. errno:%d, %s", errno, strerror(errno));
//!TODO
}
}
}
return true;
}
当可写事件发生时,IO线程回调BufferedFd::onWriteCallback函数:
void BufferedFd::onWriteCallback(short)
{
//! 如果发送缓冲中已无数据要发送了,那就关闭可写事件
if (send_buff_.readableSize() == 0) {
sp_write_event_->disable();
if (send_complete_cb_) {
++cb_level_;
send_complete_cb_();
--cb_level_;
}
return;
}
//! 下面是有数据要发送的
ssize_t wsize = fd_.write(send_buff_.readableBegin(), send_buff_.readableSize());
if (wsize >= 0) {
send_buff_.hasRead(wsize);
} else {
if (error_cb_) {
++cb_level_;
error_cb_(errno);
--cb_level_;
} else
LogWarn("write error, wsize:%d, errno:%d, %s", wsize, errno, strerror(errno));
}
}
ByteStream
在实际应用中,存在数据转发的情况,比如:
- TCP数据转成串口发送出去(或者串口数据转TCP发送)
- TCP的数据往终端输出
- 终端上的输入往TCP输出
如果存在类似的需求,可以通过设置绑定关系,轻松实现。在收到数据时,如果有绑定接收者,则应将数据直接转发给接收者。ByteStream继承体系如下:
一个示例如下,实现了一个重定向的功能:
auto sp_loop = event::Loop::New();
auto sp_stdin = new network::BufferedFd(sp_loop);
auto sp_stdout = new network::BufferedFd(sp_loop);
sp_stdin->initialize(STDIN_FILENO, network::BufferedFd::kReadOnly);
sp_stdout->initialize(STDOUT_FILENO, network::BufferedFd::kWriteOnly);
//! 将sp_stdin收到的数据转发给sp_stdout,实现重定向
sp_stdin->bind(sp_stdout);
sp_stdin->enable();
sp_stdout->enable();
总结
- 在非阻塞IO模型+IO复用编程模型中,应用层缓冲是必须的。
- 系统调用(如read, write, open, close等)涉及用户态到内核态的转换,这一过程相对耗时,尽量减少系统调用是提高程序性能的一种策略,特别是在性能敏感和高并发的系统中尤为重要。