写在前面
本文用于个人学习和回顾,采用 🇶🇦 方式记录我在项目学习的动手的时候遇到的一些疑问
物理设施层
日志宏---方便调试和观察程序运行状态
❓为什么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,线程安全
❓**加锁可以保证线程安全,给不可重入函数加把锁,是不是变成可重入了? **
线程安全 != 可重入
可重入比线程安全更严格,一个函数可重入,一定线程安全,反过来不一定
要留意死锁!
假设线程A给不可重入函数A加了把锁,现在确实线程安全了 ,但是!突然来个
SIGINT信号,系统暂停当前代码,跳转去执行信号处理函数B ,跳到线程B了,巧的是信号处理函数B内部调用了A,就会试图获取同一把锁,A没释放锁,B等不到锁,死锁了可重入函数的代码规范:
- 不使用静态和全局变量
- 不返回任何指向静态空间的指针
- 函数内部所有状态都依赖于传入的参数/局部的栈变量
- 绝不在函数内部调用不可重入的函数
缓冲区---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下recv和 send的返回值代表什么意思?
n > 0:一切正常
recv代表从内核接收缓冲区捞到了n个字节数据,放到了用户的缓冲区send代表把用户缓冲区里的数据,塞了n个字节到内核发送缓冲区
n == 0:对端关闭连接
recv:对端调用了close(),关闭了连接(发来了FIN包)send:可以忽略
n < 0也就是n == -1:必须检查errno判断发生了什么
真错误
当
errno == ECONNRESET(对端强行复位,比如拔网线或进程崩溃)、EBADF(文件描述符坏了) 等:连接发生了不可逆的物理或协议破坏,坚决不能读写假错误
- 当
errno == EAGAIN或errno == 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两个对象的作用,为什么指针类型不同?
cppstd::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)! - 结局:炸弹没有落地,析构函数不执行,踢人回调不执行。那个早就该被踢掉的断网客户端,永远留在了服务器里。定时器彻底失效!
- 添加任务:来了个连接,需要10秒后踢掉
-
所以为什么两个指针类型不同?
-
添加任务
- 表盘拿炸弹
shared_ptr,放到第十个格子 - 哈希表拿望远镜
weak_ptr看着炸弹
- 表盘拿炸弹
-
时间过去十秒
-
到期执行:表盘执行
_wheel[10].clear() -
现象:💣 完美爆炸! 表盘把手里的炸弹扔了!由于哈希表只是拿着望远镜,所以这颗炸弹再也没人拿了。炸弹落地,引用计数清零,触发析构函数,客户端被顺利踢掉!
-
刷新时间怎么做?
客户端第 5 秒发了消息,需要延长寿命。哈希表用望远镜一看(
lock()),炸弹还在。于是通知表盘:"你把炸弹从第 10 个格子拿出来,放到第 15 个格子去。"炸弹的寿命就被成功延长了
-
总结:决定事物生死的模块,必须使用 shared_ptr,只负责查询、不干涉别人生死的模块,必须使用 weak_ptr
未完待续~
