muduo网络服务器项目篇:服务器模块设计

写在前面

本文用于个人学习和回顾,采用 🇶🇦 方式记录我在项目学习的动手的时候遇到的一些疑问

物理设施层

日志宏---方便调试和观察程序运行状态

为什么localtime线程不安全?

这是C标准库早期的设计,为了方便,直接在函数内部定义了一个全局静态的struct tm变量,每次把算好的时间写进全局变量,然后返回这个变量的指针

  • 并发灾难:多线程下,线程A刚调用完localtime拿到指针,还没来的及打印,B也调用了localtime,B把变量里的时间改写了,A打印出了B改写后的时间
  • 引入localtime_r解决:_r代表可重入 ,要求使用者在栈上准备好一个struct tm lt传进去,每个线程都在自己的私有栈内存中操作,互不干扰

提到了可重入,什么是可重入?

  • 字面意思: 一个函数在执行过程中,可以再次进入调用,并且不出错
  • 举例理解:
    • 线程案例:A在执行一个函数,执行到一半,突然被OS强行打断(线程切换 或者来个中断/信号 ),此时,B也去调用了这个函数--->整个过程中,A和B都顺利拿到了正确结果,互不干扰,这个函数就是可重入的
    • 生活案例
      • **不可重入函数(公共黑板):**办公室只有一块黑板,A在算行列式,算一半去厕所了,B过来擦了黑板,开始做积分,A回来之后接着黑板上的内容继续,乱套了
      • 可重入函数(私人草稿): 办公室没黑板,每个私有草稿本(独立栈空间),A算一半去厕所,B也影响不到A,线程安全

❓**加锁可以保证线程安全,给不可重入函数加把锁,是不是变成可重入了? **

线程安全 != 可重入

  1. 可重入比线程安全更严格,一个函数可重入,一定线程安全,反过来不一定

  2. 要留意死锁!

    假设线程A给不可重入函数A加了把锁,现在确实线程安全了 ,但是!突然来个SIGINT信号,系统暂停当前代码,跳转去执行信号处理函数B ,跳到线程B了,巧的是信号处理函数B内部调用了A,就会试图获取同一把锁,A没释放锁,B等不到锁,死锁了

  3. 可重入函数的代码规范:

    • 不使用静态和全局变量
    • 不返回任何指向静态空间的指针
    • 函数内部所有状态都依赖于传入的参数/局部的栈变量
    • 绝不在函数内部调用不可重入的函数

缓冲区---Buffer模块

为什么要自己写一个Buffer?这个模块的功能是什么?实现思路是什么?

  • why:TCP是面向字节流的,数据没有边界,可能只读到了半个HTTP 请求(半包), 也可能一下读到了两个半请求(粘包)必须先存进应用层 Buffer,等业务层慢慢切割

  • what

    • **解耦:**让网络数据收发速度(依赖网络环境)和业务处理速度(依赖CPU算力和磁盘IO)解耦,让两种活动互不干扰,完美吸收了两者的速度差
    • **拼装:**TCP流式协议,解决粘包和半包问题
  • how:

    vector作为底层物理空间,读写两个指针控制分区:

    • 0,读指针\]是已经读完的头部废弃区

    • 写指针,末尾\]是还能继续写的空闲空间

怎么处理内存碎片和解决容量不足的问题?

  • 碎片整理:如果尾部空间不够,但是头部废弃空间极大,绝对不能先扩容,可以先用std::copy把有效数据挪动到索引0
  • 移动后尾部还不够装新数据,才调用_buffer.resize触发扩容

因为resize底层是先new新空间,再拷贝旧数据的,上面的操作可以避免拷贝无用数据

怎么高效读取字符串数据?

常规思路:开新内存->将Buffer数据拷贝到新内存->返回新内存,拷贝+返回临时拷贝->两次拷贝

优化:利用std::string的构造函数,直接返回这个构造函数,创建字符串时一次性分配好并直接指向Buffer的连续内存段,这样就省去一次无意义拷贝

套接字--Socket模块

这个类的作用是什么?

封装Socket繁琐的接口,对Socket的操作更加简便

流程:创建Socket-》绑定地址信息bind-》监听套接字listen-》发起新连接-》accept-》收发数据recv/send-》关闭Socket

创建一个客户端连接的步骤:

  • 创建Socket
  • 设置端口复用
  • 设置非阻塞
  • 连接服务器

创建一个服务器连接的步骤:

  • 创建Socket
  • 设置端口复用
  • 绑定端口
  • 设置非阻塞
  • 监听Socket

为什么要端口复用,不复用会怎么样?

主动关闭连接的一方,它的端口会进入长达2MSL的TIME_WAIT状态,如果不设置SO_REUSEADDR,这期间重启,会报错 Address already in use 拒绝重启

非阻塞IO下recvsend的返回值代表什么意思?

  • n > 0:一切正常

    • recv代表从内核接收缓冲区捞到了n个字节数据,放到了用户的缓冲区
    • send代表把用户缓冲区里的数据,塞了n个字节到内核发送缓冲区
  • n == 0:对端关闭连接

    • recv:对端调用了close(),关闭了连接(发来了FIN包)
    • send:可以忽略
  • n < 0也就是n == -1:必须检查errno判断发生了什么

    • 真错误

      errno == ECONNRESET (对端强行复位,比如拔网线或进程崩溃)、EBADF (文件描述符坏了) 等:连接发生了不可逆的物理或协议破坏,坚决不能读写

    • 假错误

      • errno == EAGAINerrno == EWOULDBLOCK
        • 对于 recv 的含义 :内核接收缓冲区已经被你彻底抽干了 !一滴数据都不剩了。因为你是非阻塞模式,操作系统不能让你卡在这里等,所以给你报个 EAGAIN 让你赶紧去干别的
        • 对于 send 的含义 :内核发送缓冲区已经被你彻底塞满了 !一滴空间都没了。不能卡在这里等网卡发数据,所以报个 EAGAIN 让你先回去
      • errno == EINTR:系统来了更紧急的信号,可以选择忽略并重试

核心事件驱动层

Channel--中介翻译官

Channel这个类有什么作用?

底层epoll只认识描述符fd,上层业务只认识各种回调函数,Channel类对一个描述符进行事件监控管理,把一个具体的fd、它关心的事件以及对应的回调函数,强绑定在一起,联系上下层的桥梁

epoll的宏操作细节

  • EPOLLIN(可读事件) :代表"内核接收缓冲区里有数据了",注意它的多重身份 :对于普通 Socket,它代表收到客户端消息了;对于 Acceptor 的 Socket,它代表有新客户端连上来了;对于 timerfd,它代表定时器响了;对于 eventfd,它代表别的线程来敲门了!
  • EPOLLOUT(可写事件):代表"内核发送缓冲区有空闲位置了",只要网卡没被塞满,它就一直触发,一旦把数据发完,就要关闭可写时间监控,否则就会造成CPU空转,占用大量CPU资源!
  • EPOLLHUP(挂断事件):对端正常关闭了连接
  • EPOLLERR(错误事件):发生了极其严重的底层物理或协议错误
  • EPOLLPRI(紧急带外数据):TCP 的紧急指针数据

怎么实现多路转接?

epoll_wait被唤醒,丢给Channel一个revents,这个变量是多个宏的组合,要返回给上层知道哪些事件被触发

revents和上面的宏按位与,把组合拆解开,针对不同的成分调用不同的回调函数

Poller--底层监控,对任意描述符进行监控

Poller这个类的作用是什么?

封装了epoll的操作接口,对任意描述符进行监控,是整个服务器的心脏

核心是Poller::Poll里的epoll_wait调用

  • 动力源泉:一个EventLoop线程,在没有任务时,被epoll_wait管理,被OS挂起,交出CPU使用权
  • 唤醒枢纽:当网卡收到一个数据包,内核把数据塞进Socket,顺着红黑树找到对应的节点,放进就绪队列中,唤醒休眠的线程
  • 心脏跳动:线程从epoll_wait中唤醒,相当于心脏跳了一次 ,拿到了活跃的nfds个事件,事件被压入任务池中,流入各个Channel进程处理,最后流进EventLoop执行跨线程任务

Poller和Channel是如何配合的?

Poller 是内核 epoll 的封装,它脑子里只有 epoll_event 结构体和数字 fd,它完全不知道什么叫回调,什么叫业务

需要监听一个事件时,Channel 会把自己的 fd 和事件打包,交给 Poller 去挂在红黑树上;当事件发生时,Poller 把事件摘下来,塞回给对应的 Channel,让 Channel 自己去触发对应的回调

它们俩在 EventLoop 这个大管家的统筹下,完美地实现了底层IO机制与上层业务逻辑的绝对解耦

TimerWheel--定时器任务

什么是时间轮?

如果设计一个计数器,用std::map存大量数据,插入和删除事件复杂度为O(logN)O(logN)O(logN),还有哈希冲突的问题。而时间轮思想就是时钟的表盘 ,假设表盘有60个槽位,那么就能对应std::vector<vector>->外层代表未来的一秒钟,内层代表具体秒数上,所有要执行的任务的集合 。假设现在秒针指向1,想要添加一个5秒后的任务,直接计算(1 + 5) % 60 = 6,把任务添加到第六个槽位即可,O(1)O(1)O(1)的复杂度。秒针走到哪里,就清空哪里并执行任务

解释_wheel、_timers两个对象的作用,为什么指针类型不同?

cpp 复制代码
std::unordered_map<uint64_t, weakTask> _timers;
std::vector<std::vector<ptrTask>> _wheel;
  • _wheel:表盘(二维数组)

    • 作用 :外层 vector 是表盘上的刻度(比如 60 秒),内层 vector 是挂在某个刻度上的"待处决任务清单"

    • 工作机制 :随着时间流逝,秒针 _tick 往前走。走到哪个刻度,就直接调用 _wheel[_tick].clear() 把那个桶清空

    • 为什么是二维数组? 因为在同一秒(比如第 10 秒),可能有 500 个客户端同时到期,内层 vector 就是用来把这 500 个任务装在一起的

  • _timers:哈希表,用于查找任务

  • 作用 :用于**O(1)O(1)O(1) 时间复杂度**的极速查找

  • 为什么需要它? 设想一下,一个客户端原本在第 10 秒要被踢掉(任务挂在第 10 个槽里)但是它在第 5 秒突然发了一条消息过来,活跃了一下。此时需要**刷新(延长)**它的寿命,把它挪到第 15 秒去

    如果没有 _timers,要去遍历 _wheel 这个二维数组找这个任务吗?( O(N)O(N)O(N) )

    有了 _timers,只需用连接 ID 去map里一查,瞬间就能把这个任务抓出来,重新丢到第 15 秒的桶里

对于指针类型不同,需要更深刻理解智能指针:

用一个定时炸弹的比喻,按照"核心本质 -> 怎么用(沙盘推演) -> 价值意义"的思路梳理:

  • 本质:谁拿着炸弹?

    可以把每一个定时任务(TimerTask)想象成一颗定时炸弹 ,炸弹的设定是:当最后一个人把它扔掉(智能指针销毁,触发析构函数)时,炸弹就会爆炸(执行踢人的回调函数

    • shared_ptr(共享指针) :代表双手真正拿着炸弹的人。只要他的手不松开,炸弹就绝对不会掉地上爆炸
    • weak_ptr(观察指针) :代表拿望远镜远远看着炸弹的人。他只负责找炸弹在哪,他手里根本没拿炸弹。他放下望远镜,对炸弹爆不爆炸没有任何影响
  • 表盘用 weak_ptr,哈希表用 shared_ptr,会发生什么?

    • 添加任务:来了个连接,需要10秒后踢掉
      • 哈希表拿炸弹shared_ptr
      • 表盘的第十个各自里,放了个望远镜weak_ptr看着炸弹
    • 时间过去十秒
    • 到期执行:按照逻辑,
    • 现象:💣 哑火了! 表盘只是把第 10 个格子里的望远镜给扔了 。炸弹掉地上了吗?没有!因为哈希表还死死地用双手拿着这颗炸弹(shared_ptr)!
    • 结局:炸弹没有落地,析构函数不执行,踢人回调不执行。那个早就该被踢掉的断网客户端,永远留在了服务器里。定时器彻底失效
  • 所以为什么两个指针类型不同?

    • 添加任务

      • 表盘拿炸弹shared_ptr,放到第十个格子
      • 哈希表拿望远镜weak_ptr看着炸弹
    • 时间过去十秒

    • 到期执行:表盘执行_wheel[10].clear()

    • 现象:💣 完美爆炸! 表盘把手里的炸弹扔了!由于哈希表只是拿着望远镜,所以这颗炸弹再也没人拿了。炸弹落地,引用计数清零,触发析构函数,客户端被顺利踢掉!

    • 刷新时间怎么做?

      客户端第 5 秒发了消息,需要延长寿命。哈希表用望远镜一看(lock()),炸弹还在。于是通知表盘:"你把炸弹从第 10 个格子拿出来,放到第 15 个格子去。"炸弹的寿命就被成功延长了

总结:决定事物生死的模块,必须使用 shared_ptr,只负责查询、不干涉别人生死的模块,必须使用 weak_ptr

未完待续~

相关推荐
wuxuand2 小时前
2026论文阅读——零日攻击无处遁形:一种用于网络入侵检测的新型对比损失函数
网络·人工智能·深度学习
坚持就完事了2 小时前
Linux文件路径
linux·运维·服务器
a***71632 小时前
IDEA连接SQL server数据库(保姆级详细且必坑,包括防火墙、 SQL Server 网络配置等问题解决)
网络·数据库·intellij-idea
啊哈哈121382 小时前
计算机三级备考(七)——高级数据库查询
服务器·数据库
加农炮手Jinx2 小时前
Flutter for OpenHarmony:postgres 直连 PostgreSQL 数据库,实现 Dart 原生的高效读写(数据库驱动) 深度解析与鸿蒙适配指南
网络·数据库·flutter·华为·postgresql·harmonyos·鸿蒙
新缸中之脑2 小时前
用Gws+Valyu实现晨报自动化
运维·自动化·php
橙子也要努力变强2 小时前
共享内存通信
网络·c++·操作系统
qq_283720052 小时前
WebGL基础教程(十三) :玩转矩阵,从 0 到 1 玩转 3D 动画(新手也能秒懂矩阵变换)
运维·nginx
橘子132 小时前
网络层IP协议
网络·tcp/ip·智能路由器