目录
[一、Epoll 与服务器框架](#一、Epoll 与服务器框架)
[1. 核心构成](#1. 核心构成)
[2. 相关接口](#2. 相关接口)
[3. ET 和 LT 模式](#3. ET 和 LT 模式)
[4. ET 和 LT 的效率区别](#4. ET 和 LT 的效率区别)
[5. ET + 非阻塞高性能服务器](#5. ET + 非阻塞高性能服务器)
[1. Channel](#1. Channel)
[2. Poller](#2. Poller)
[3. TimeWheel(时间轮)](#3. TimeWheel(时间轮))
[4. EventLoop](#4. EventLoop)
[5. LoopThread 和 LoopThreadPool(线程与线程池)](#5. LoopThread 和 LoopThreadPool(线程与线程池))
[6. Connection(连接管理)](#6. Connection(连接管理))
[7. Acceptor(接收器)](#7. Acceptor(接收器))
[8. TcpServer](#8. TcpServer)
[1. TcpServer 构造](#1. TcpServer 构造)
[2. Acceptor 创建](#2. Acceptor 创建)
[3. 新连接](#3. 新连接)
[4. 创建新的 Connection](#4. 创建新的 Connection)
[5. 设置超时销毁](#5. 设置超时销毁)
[6. 启动读](#6. 启动读)
[1. 时钟计时](#1. 时钟计时)
[2. 时间轮智能指针](#2. 时间轮智能指针)
[3. 及时唤醒](#3. 及时唤醒)
[4. Any 类](#4. Any 类)
[5. RunInLoop:在线程里执行](#5. RunInLoop:在线程里执行)
[6. 正则表达式(解析 HTTP 报头)](#6. 正则表达式(解析 HTTP 报头))
[7. Buffer 缓冲区](#7. Buffer 缓冲区)
[8. 异步初始化](#8. 异步初始化)
[9. 先后顺序问题](#9. 先后顺序问题)
[10. 资源占用问题](#10. 资源占用问题)
[五、Debug 分享](#五、Debug 分享)
[1. 读写回调](#1. 读写回调)
[2. 命名规则(重点)](#2. 命名规则(重点))
[3. 大文件传输](#3. 大文件传输)
一、Epoll 与服务器框架
1. 核心构成
红黑树
-
存储节点,包含 fd、events 等信息
-
用户告诉操作系统要关心哪个文件的哪些信息(如 3 号文件描述符的读写信息)
就绪队列
-
内核告诉用户哪些节点的哪些事件已经处理好了
-
将就绪的节点放到就绪队列里
回调函数
-
网络协议栈有回调机制,一旦底层有预定事件就绪就会被触发
-
在 epoll 中,红黑树上的节点会被设定回调机制,触发时就将节点挂到消息队列上
-
一个节点可以用指针的方式同时存在于消息队列和红黑树里
-
网卡收到信息后,触发设定的中断,在中断向量表上查到方式,放到就绪队列里
2. 相关接口
| 接口 | 作用 |
|---|---|
epoll_create |
创建 epoll 模型,返回文件描述符,可以和文件系统关联。struct file 文件结构体有一个 data 指针,指向 epoll 对象 |
epoll_ctl |
控制 epoll 描述符,有 add、mod、del 等模式,告诉操作系统关心哪个 fd 的哪个事件 |
epoll_wait |
操作系统告诉用户哪些事件已就绪 |
3. ET 和 LT 模式
LT(水平触发):epoll 默认模式
- 如果事件没有处理,就会一直通知事件就绪
ET(边缘触发)
-
事件只通知一次
-
如果上层不处理,接下来就不再提醒了
因此,LT 模式可以不及时处理,因为反正还会继续通知(当然也可以立马处理);而 ET 模式必须立马处理,因为如果不处理,资源可能一直被占用,甚至导致内存泄漏。
4. ET 和 LT 的效率区别
ET 的效率更高,不是因为 ET 比 LT 引入了什么更高效的算法------ET 能做到的 LT 也能做到。
而是因为 ET 只要一次消息不处理就会导致问题,而 LT 不会。因此,ET 能够倒逼程序员写出更加高效的代码。
5. ET + 非阻塞高性能服务器
使用 ET + 非阻塞读取来实现。
其中,单线程接收新连接,多线程处理任务。
问题:如果一个连接的读由线程 A 处理,写由线程 B 处理,那么这个连接就直接成为了临界资源,需要加锁。多少个连接就要有多少个锁(或信号量),这个开销相当巨大。
解决方案 :不让连接成为临界资源。一个连接由指定的一个线程完成它的读写事件,这样就不用争抢锁了。这个模式就叫做 Reactor 模式。
二、服务器重要类的框架
这里只展示重要类的主要成员以及大概用处,具体实现会在下文详述。
1. Channel
cpp
class Channel {
using EventCallBack = std::function<void()>;
private:
EventLoop *_loop;
int _fd;
uint32_t _events;
uint32_t _revents;
EventCallBack _read_callback;
EventCallBack _write_callback;
EventCallBack _erro_callback;
EventCallBack _close_callback;
EventCallBack _event_callback;
};
作用:底层设置关心的事件以及处理相关事件,一个连接对应一个 Channel。
-
Loop 指针:指向循环非阻塞等待的类,用于将 events 设置进 loop 里,将就绪的事件从 loop 取出设置到 revents 中以备处理
-
Fd 编号:可以在哈希表中找到 Channel
2. Poller
cpp
class Poller {
private:
struct epoll_event _evs[EPOLLMAXSIZE];
std::unordered_map<int, Channel *> _channel;
int _epfd;
};
作用:Channel 的底层,用于查找就绪队列,并将就绪的消息取出来,交给对应的 Channel 执行(上层有很多的 Channel)。
-
epoll_event _evs:存放就绪的文件描述符 -
_epfd:epoll 对应的文件描述符,用于找到就绪事件以及对应的 fd -
_channel哈希表 :从_evs对应的就绪 fd 中找到对应的 Channel,并让对应的 Channel 处理就绪事件
3. TimeWheel(时间轮)
cpp
class TimeWheel {
uint32_t _tick;
std::vector<std::vector<PtrTask>> _wheel;
int _timerfd;
EventLoop *_loop;
std::unique_ptr<Channel> _timer_channel;
};
作用:销毁太长时间没有反应的连接。
-
_timerfd:时钟,每隔一秒向对应的文件写消息,读到一个消息就是 1 秒 -
_tick:当前时间,走到哪里就将_wheel数组里对应位置的任务释放掉 -
其中
_wheel里存储的都是析构就会执行回调函数的类(RAII 设计),因此clear相当于执行回调函数 -
_loop指针:TimeWheel 也需要每秒读取一个消息,因此可以直接将它看成一个 Channel,启动读事件监控。所以需要放到对应的 loop 中,就需要 loop 指针来构造 Channel
4. EventLoop
就是之前说的 loop。
cpp
class EventLoop {
using Functor = std::function<void()>;
private:
std::vector<Functor> _task;
int _event_fd;
std::unique_ptr<Channel> _event_channel;
Poller _poller;
std::thread::id _thread_id;
TimeWheel _time_wheel;
};
作用:将之前说的 Poller、TimeWheel、Channel 整合到一起管理。
-
一个线程对应一个 EventLoop,同时也对应一个监控的 Poller 和一个时间轮(一个线程对应一个时间轮,互不干扰)
-
_event_fd:添加任务时唤醒,以防没有事件到来导致 Poller 阻塞,使得线程阻塞在epoll_wait那里,而已经就绪的任务得不到回调 -
_thread_id:由于是多线程服务器,需要判断当前线程 ID 是否是 EventLoop 所在的线程 -
_poller:Poller 在事件就绪时会就将绪时间放到 vector 中,loop 就可以用 Poller 成员的这个 vector 去处理就绪的事件
5. LoopThread 和 LoopThreadPool(线程与线程池)
cpp
class LoopThread {
private:
std::mutex _mutex;
std::condition_variable _cond;
EventLoop *_loop;
std::thread _thread;
};
class LoopThreadPool {
private:
int _thread_cnt;
int _next_idx;
EventLoop *_baseloop;
std::vector<LoopThread *> _threads;
std::vector<EventLoop *> _loops;
};
线程池管理线程(包括线程和对应的 EventLoop)。由于管理任务的是 EventLoop,因此要有两个 vector:一个存放线程,一个存放 EventLoop。收到新连接时,用 NextLoop 函数调用新的线程(即新的 EventLoop)来处理。
线程管理 EventLoop。LoopThread 说白了就是创建线程用的,加这么一个类是为了更加模块化一点,并且更安全(后面会详述)。
6. Connection(连接管理)
管理套接字。
cpp
class Connection;
typedef enum {
DISCONNECTED,
DISCONNECTING,
CONNECTING,
CONNECTED
} ConnStatu;
using PtrConnection = std::shared_ptr<Connection>;
class Connection : public std::enable_shared_from_this<Connection> {
private:
uint64_t _conn_id;
uint32_t _sockfd;
ConnStatu _statu;
EventLoop *_loop;
Socket _socket;
Channel _channel;
buffer _in_buffer;
buffer _out_buffer;
Any _Context;
bool _enable_inactive_release;
ConnectedCallBack _connected_callback;
MessageCallBack _message_callback;
ClosedCallBack _closed_callback;
AnyeventCallBack _anyevent_callback;
ClosedCallBack _server_closed_callback;
};
作用:将 Channel 和缓冲区结合,是用户能接触到的最底层。
这个类是上下层分界线,在这里是两套回调函数的碰面点:
-
下层回调:读、写、错误、关闭、任意(刷新时间轮)等函数,由写服务器库的人(开发者)需要弄好的函数,在这里注册上去
-
上层回调 :
OnMessage(处理发送消息)、Close(关闭)等回调函数,由服务器使用者传进来
各成员的作用:
-
Channel:接收 Connection 类里面的回调函数
-
Loop 指针:用于时间轮操作。由于上层基本上操作的是这一层而不是更底层的 TimeWheel,因此在这个类中要保留更新时间轮的接口备用(虽然基本不会用)
-
_sockfd:读消息放到缓冲区中,以及将处理好的消息从写缓冲区中发送出去 -
_Context:是一个 Any 类,可以放任何东西,用于存放上层的协议,即写缓冲区在反序列化时的草稿(实现方式之后讲,会在下一篇博客详细讲解)
7. Acceptor(接收器)
接收套接字。
cpp
class Acceptor {
private:
Socket _socket;
EventLoop *_loop;
Channel _channel;
using AcceptCallback = std::function<void(int)>;
AcceptCallback _accept_callback;
};
Loop 指针:和 Channel 原因一样。上层创建时将读事件(接收到新的连接,对应文件描述符状态改变就会触发读事件)放到自己的 loop 里,因此它也需要读事件监听,构造下层 Channel 时要用 loop 指针。
8. TcpServer
即最终的服务器类。
cpp
class TcpServer {
private:
int _port;
uint64_t _next_id;
EventLoop _base_loop;
Acceptor _acceptor;
LoopThreadPool _pool;
std::unordered_map<uint64_t, PtrConnection> _conns;
int _timeout;
bool _enable_inactive_release;
ConnectedCallBack _connected_callback;
MessageCallBack _message_callback;
ClosedCallBack _closed_callback;
AnyeventCallBack _anyevent_callback;
};
-
上层从这里设置回调函数,将其传给每一个 Connection 类
-
设置时间轮是否运行、超时时间
-
_base_loop:服务器自己创建线程去接收新的连接,_base_loop就是这个线程的监听读事件的 loop -
_next_id:每个连接都要有一个编号,用哈希表将它们管理起来
三、服务器运行过程模拟
以下面的简单服务为例:
cpp
void OnMessage(const PtrConnection& conn, buffer* buff) {
buff->MoveReadOffset(buff->ReadableSize());
std::string msg = "hello";
LOG(LogLevel::DEBUG) << "收到" << std::this_thread::get_id();
conn->Send(msg.c_str(), msg.size());
}
void OnConnected(const PtrConnection& conn) {
LOG(LogLevel::INFO) << "新连接" << std::this_thread::get_id();
}
void OnClosed(const PtrConnection& conn) {
LOG(LogLevel::INFO) << "关闭连接" << std::this_thread::get_id();
}
int main() {
TcpServer server(8081);
server.SetThreadCount(2);
// server.EnableInactiveRelease(10);
server.SetClosedCallBack(OnClosed);
server.SetConnectedCallBack(OnConnected);
server.SetMessageCallBack(OnMessage);
server.Start();
return 0;
}
1. TcpServer 构造
上层传入 OnMessage、OnConnected、OnClosed 三个极其简单的事件处理函数,放到 server 的成员里面。
2. Acceptor 创建
构造函数初始化一组回调函数以及 _base_loop 以及 Acceptor。
Acceptor 设置 TcpServer 里的读回调函数如下:
cpp
void NewConnection(int fd) {
_next_id++;
PtrConnection conn(new Connection(_pool.NextLoop(), _next_id, fd));
conn->SetMessageCallBack(_message_callback);
conn->SetClosedCallBack(_closed_callback);
conn->SetConnectedCallBack(_connected_callback);
conn->SetAnyeventCallBack(_anyevent_callback);
conn->SetSrvClosedCallBack(std::bind(&TcpServer::RemoveConnection, this, std::placeholders::_1));
if (_enable_inactive_release) conn->EnableInactiveRelease(_timeout);
conn->Established();
_conns.insert(std::make_pair(_next_id, conn));
}
之后再将 Acceptor 的成员 Channel 的读事件监控开启。
读监控事件开启的过程 :Acceptor 里面的 loop 指针指向 _base_loop,用 _base_loop 指针构造 Channel,此时 Channel 就挂到 _base_loop 上了,再将 Channel 的读事件打开,更新 _base_loop。
3. 新连接
有读事件时,Poller 就会检测到,并且用哈希表将 fd 转为对应的 Channel,将就绪事件取到 _evs 数组里。
之后 _base_loop 就可以收到这个 Channel 数组,遍历它去执行对应的任务。
4. 创建新的 Connection
拿到新连接之后,Acceptor 就会执行自己的读事件回调函数。
即创建新的 Connection,然后设置上层传下来的回调函数。由于可能需要发消息,因此这些函数的参数都有 Connection 指针。发消息时调用 Connection 的发送,并且由于用户没有必要接触到最底层,因此用 Connection 为参数而不是 Socket。
5. 设置超时销毁
设置 TimeWheel,并且将 TimeWheel 的读事件打开,用来计时。因为 timerfd 会固定地向对应的文件描述符里写消息,收到消息就是 1 秒。
6. 启动读
在 Connection 中启动连接的读函数,连接开始启用。
四、项目亮点
1. 时钟计时
cpp
static int CreateTimerFd() {
int timfd = timerfd_create(CLOCK_MONOTONIC, 0);
if (timfd < 0) {
// LOG(LogLevel::ERROR) << "时钟创建失败";
abort();
}
struct itimerspec timer;
timer.it_interval = {1, 0};
timer.it_value = {1, 0};
timerfd_settime(timfd, 0, &timer, NULL);
return timfd;
}
时间轮模块中需要 1 秒时间指针前进一格,清空对应的任务。
cpp
~TimeTask() {
if (_cancel == 0)
_tb();
_release();
}
任务析构时,使用 RAII 执行回调函数。
timerfd 是 Linux 的一个计时函数,每隔对应时间就向对应文件写 1。如果文件的 1 没取走,下一次就写入 2。
cpp
int ReadTimeFd() {
uint64_t num = 0;
int n = read(_timerfd, &num, sizeof(num));
if (n < 0) {
LOG(LogLevel::ERROR) << "时钟读取错误";
abort();
}
return num;
}
void OnTime() {
int r = ReadTimeFd();
for (int i = 0; i < r; i++) {
RunTimerTask();
}
}
为了防止没有读到而干扰计时,读到几,指针就前进几。
2. 时间轮智能指针
任务对象用智能指针(shared_ptr)管理。当连接有消息时,说明不是死连接,用 weak_ptr 构造出新的 shared_ptr,此时引用计数增加。
cpp
void TimerAddInLoop(uint64_t id, uint32_t time_out, const task_func &tb) {
// LOG(LogLevel::DEBUG) << "add in 时间轮" << id;
PtrTask pt(new TimeTask(id, time_out, tb));
pt->SetRelease(std::bind(&TimeWheel::RemoveTimer, this, id));
int pos = (time_out + _tick) % _capacity;
_wheel[pos].push_back(pt);
_timers[id] = WeakTask(pt);
}
void TimerFreshInLoop(uint64_t id) {
// LOG(LogLevel::DEBUG) << "fresh" << id;
auto pos = _timers.find(id);
if (pos == _timers.end()) {
return;
}
PtrTask pt = pos->second.lock();
int det = (pt->DelayTime() + _tick) % _capacity;
_wheel[det].push_back(pt);
}
之后时间往后走,shared_ptr 会不断析构,直到时间轮数组上的 shared_ptr 被析构完,引用计数为 0,就将本体释放掉。
cpp
void RunTimerTask() {
_tick = (_tick + 1) % _capacity;
_wheel[_tick].clear();
}
3. 及时唤醒
由于 loop 中的 epoll_wait 是阻塞的,因此如果很久没有新的就绪事件,对应的线程就会阻塞在那里。此时如果任务队列里有任务,就执行不了。
因此,需要任务队列里一有新任务就唤醒线程,执行它。
cpp
static int CreateEventFd() {
int efd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC);
if (efd < 0) {
// LOG(LogLevel::ERROR) << "eventfd创建";
abort();
}
return efd;
}
void ReadEventFd() {
uint64_t ret = 0;
int r = read(_event_fd, &ret, sizeof(ret));
if (r < 0) {
if (errno == EINTR || errno == EAGAIN)
return;
// LOG(LogLevel::ERROR) << "eventfd读失败";
abort();
}
}
void WeakUpEventFd() {
uint64_t val = 1;
int ret = write(_event_fd, &val, sizeof(val));
if (ret < 0) {
if (errno == EINTR || errno == EAGAIN)
return;
// LOG(LogLevel::ERROR) << "eventfd写失败";
abort();
}
}
eventfd 是一个极低成本的线程通信方案,用 8 字节数据通信。
当这个线程在阻塞时,其它线程的 loop 收到了就绪的事件,发现有一个就绪事件不是自己的,就会将它放到对应线程的任务队列中(这一步等会说)。
此时就会触发向 eventfd 写数字,唤醒阻塞的线程,要它先去执行任务队列再回来阻塞。同时这样写也可以让任务及时得到处理。
4. Any 类
由于网络通信的正文部分可能是任意类型,因此需要像 Python 一样的万能类型:Any a = 1;、Any a = "name";。
cpp
class Any {
public:
private:
class Holder {
public:
virtual ~Holder() {}
virtual const std::type_info& type() = 0;
virtual Holder* clone() = 0;
};
template <class T>
class PlaceHolder : public Holder {
public:
PlaceHolder(const T& val) : _val(val) {}
const std::type_info& type() {
return typeid(T);
}
Holder* clone() {
return new PlaceHolder<T>(*this);
}
~PlaceHolder() {}
public:
T _val;
};
Holder* _content;
public:
Any() : _content(NULL) {}
Any& swap(Any& rhs) {
std::swap(_content, rhs._content);
return *this;
}
template <class T>
Any(const T& val) : _content(new PlaceHolder<T>(val)) {}
Any(const Any& other) : _content(other._content ? other._content->clone() : NULL) {}
~Any() {
delete _content;
}
template <class T>
T* get() {
assert(_content->type() == typeid(T));
return &(((PlaceHolder<T>*)_content)->_val);
}
template <class T>
Any& operator=(const T& val) {
Any(val).swap(*this);
return *this;
}
Any& operator=(const Any& other) {
Any(other).swap(*this);
return *this;
}
};
这里使用的写法叫做类型擦除 ,C++17 的 std::any 类也是类似的写法。
结构:Any 类里面有 PlaceHolder 类继承 Holder 类。
示例 :Any a = 1;
-
模板推断类型:
Any(const T& val) : _content(new PlaceHolder<T>(val)) {}这是拷贝构造。由于是函数模板,编译器推断出这个 T 是
int类型,接着 1 就存储在_content中了。 -
锁住类型 :
PlaceHolder 推断出是
int以后,编译器就实例化了一份int版本的 Any,之后就和int*类没区别了。
为什么要设计成继承?
因为 C++ 不允许成员变量大小飘忽不定,必须在编译时确定下来。因此只能写一个基类,确定大小。但派生类可以不确定大小,因为继承就是一个指针,派生类即使不算在成员变量里。
5. RunInLoop:在线程里执行
上文说过,需要一个线程服务一个连接,不能多个线程服务一个连接,否则就需要锁,导致大量开销。
但是由于 epoll 的 wait 是一次性将所有就绪事件放到就绪队列里,它并不知道这个任务是不是现在取出就绪队列的线程的。
因此,loop 用 fd 查询到 Channel 时,需要先看一下当前线程是否和 Channel 连接的线程对得上号。对上了就执行,否则放到就绪队列里。
cpp
void RunInLoop(const Functor &cb) {
if (IsInLoop())
return cb();
return QueueInLoop(cb);
}
这个函数可以确保传进来的函数运行在对应的线程上。
6. 正则表达式(解析 HTTP 报头)
HTTP 报头的请求格式是固定的,可以用正则表达式进行解析。
cpp
std::regex e("(GET|HEAD|POST|PUT|DELETE) ([^?]*)(?:\\?(.*))? (HTTP/1\\.[01])(?:\\n|\\r\\n)?", std::regex::icase)
解释:
-
(GET|HEAD|POST|PUT|DELETE):匹配请求方法。用|连接,匹配到任何一个就可以放到结果里面。 -
([^?]*):非问号,匹配不是问号的字符。*表示任意多个。即一直匹配到?为止,将路径拿下来。 -
(?:\\?(.*))?:?:表示非捕获组,结果不需要。后面的\\?表示转义,就是一个真正的问号。(.*)表示括号里的内容要捕获,匹配任意字符,直到空格、换行和等号。 -
(HTTP/1\\.[01]):可能是 1.0 或 1.1,.也需要转义。 -
(?:\\n|\\r\\n):后面可能是\n或\r\n,不需要捕获。
7. Buffer 缓冲区
使用两个指针(读指针和写指针)。读指针左边、写指针右边表示无意义数据,因此数据可以覆盖。
写入数据的逻辑:
-
右边空间可以写:直接放到右边
-
左边 + 右边空间可以写:将数据拷贝到开头,再将数据写到右边
-
写不下:扩容
cpp
void EnsureWriteSpace(uint64_t len) {
if (len <= TailIdleSize())
return;
else if (len <= TailIdleSize() + HeadIdleSize()) {
uint64_t rsz = ReadableSize();
std::copy(ReadPosition(), ReadPosition() + rsz, _buffer.begin());
_read_idx = 0;
_write_idx = rsz;
} else {
_buffer.resize(_write_idx + len);
}
}
void Write(const void *data, uint64_t len) {
if (len == 0)
return;
EnsureWriteSpace(len);
const char *d = (const char *)data;
std::copy(d, d + len, WritePosition());
}
8. 异步初始化
由于线程池管理线程,且有新的连接时,线程池接收并分配线程用的是 loop* 而不是线程类。
因此线程池要有两个 vector:一个管理线程,另一个管理 loop*,虽然线程和 loop* 是一一对应的关系。
在程序开始时就要将线程对应的 loop 指针取出来放到 vector 中。
cpp
void Create() {
if (_thread_cnt > 0) {
_threads.resize(_thread_cnt);
_loops.resize(_thread_cnt);
for (int i = 0; i < _thread_cnt; i++) {
_threads[i] = new LoopThread();
_loops[i] = _threads[i]->GetLoop();
}
}
}
但是获取 loop 指针时情况较为复杂:
-
线程创建好了 loop,线程池再取:此时没问题,能工作。
-
线程没有创建好 loop,线程池就要取:此时直接是野指针。
由于线程调度时都有时间片,因此谁先谁后说不清,所以需要控制状态。用锁控制:
cpp
std::mutex _mutex;
std::condition_variable _cond;
void ThreadEntry() {
EventLoop loop;
{
std::unique_lock<std::mutex> lock(_mutex);
_loop = &loop;
_cond.notify_all();
}
loop.Start();
}
LoopThread() : _loop(NULL), _thread(std::thread(&LoopThread::ThreadEntry, this)) {}
EventLoop* GetLoop() {
EventLoop* loop = NULL;
{
std::unique_lock<std::mutex> lock(_mutex);
_cond.wait(lock, [&](){ return _loop != NULL; });
loop = _loop;
}
return loop;
}
开始时 loop 构造为 NULL。
情况1 :线程执行 ThreadEntry 函数,先拿到锁。此时主线程调用 GetLoop 函数,由于锁已经被拿走了,所以只能等。之后 loop 创建完了,锁释放,主线程拿到锁,就可以拿到 loop 指针了。
情况2 :主线程先调用 GetLoop 函数,拿到锁,但条件变量说:此时 loop 为空,你先休眠。接着就休眠,将锁释放。线程拿到锁,构造 loop 之后条件变量让主线程起来,接着主线程拿到了 loop 指针。
因此,不管先后顺序,都可以正确执行。
9. 先后顺序问题
-
启动非活跃监控不能在构造函数执行:因为服务器压力大时构造可能会花很长时间,如果构造时就启动时间轮计时,可能会误杀活跃的连接。
-
定时销毁任务必须在读事件启动前 :如果读事件触发,此时又销毁非活跃连接,就可能报错。因此在读事件之前都要先通过
eventcallback刷新一下活跃度再读。 -
写完后可以将写事件监控关闭。
10. 资源占用问题
由于一个连接可能专用一个线程很久,此时后面的连接的任务在任务队列后面,得不到响应,时间轮可能就会认为那些是不活跃连接,从而误杀。
解决方式:将杀掉连接作为一个任务放到后面的任务队列里(理论上会在读事件后面)。
之后由于会先执行读事件,刷新活跃度,时间轮里的 shared_ptr 多一份,因此不会断掉连接。
cpp
void RemoveConnection(const PtrConnection& conn) {
_base_loop.RunInLoop(std::bind(&TcpServer::RemoveConnectionInLoop, this, conn));
}
void RemoveConnectionInLoop(const PtrConnection& conn) {
int id = conn->Id();
auto it = _conns.find(id);
if (it != _conns.end()) {
_conns.erase(it);
}
}
先运行 RunInLoop 版本的,将真正杀掉连接的函数放到任务队列。
五、Debug 分享
由于这个项目极其复杂,各种调用、包装器、构造、RAII 等乱飞,因此 debug 也非常困难。下面分享几个我踩过的坑。
1. 读写回调
现象:在测试 loop 的回调时,我用客户端向服务端发送了数据。但是服务端的接收消息日志没有打印,反而客户端源源不断有消息冒出来。
排查过程:简单来说就是该读不读、不该写就一直写。可以推测可能是读写函数写反了。
结果:
cpp
void SetReadCallBack(EventCallBack read_callback) {
_read_callback = read_callback;
}
void SetWriteCallBack(EventCallBack write_callback) {
_write_callback = write_callback;
}
由于上面的 Channel 类会有大量的重复代码,因此复制粘贴时将 read 粘贴成了 write。
2. 命名规则(重点)
现象 :在普通连接时没问题,读写监控、超时中断都没问题。在用 webbench 测试时,如果我服务端创建 100 个线程接收,webbench 用 98/99 个线程访问没问题。但是一旦到 101/102 个就会发生错误,而且是薛定谔的错误------可能是时间轮中智能指针的段错误,也可能是一个我随手加的断言,基本上不会断言错误的地方却断言错误了。
排查过程:段错误,我考虑了各种情况:是时间轮没有正常执行,还是 loop 等对象析构了?
结果:
cpp
void RemoveConnectionInLoop(const PtrConnection& conn) {
int id = conn->Id();
auto it = _conns.find(id);
if (it != _conns.end()) {
_conns.erase(it);
}
}
这个函数的 ID 写成了 FD。
为什么会出现这个错误(重点):
这个错误非常有意思。
首先,id 是从 1 开始编号的,而 fd 是从 4 开始编号的(0、1、2 表示标准输入等,3 为监听 fd)。因此 1 号 ID 对应 4 号 FD。
当访问数小于线程数时没问题,因为此时 1 号任务释放 4 号任务,2 号任务释放 5 号任务......以此类推。
cpp
if (it != _conns.end()) {
_conns.erase(it);
}
此外,由于我有"找不到直接返回"的预防性编程,因此即使释放一个不存在的任务也没问题。
但是,当访问线程变为 101 个时就会出错。因为 101 号任务会转一圈,放到第一个线程上。此时执行超时销毁时,由于 101 号任务已经被 98 号任务移除了,但 1 号任务还在,因此 1 号线程的时间轮还没停,还会销毁一次 101 号任务,导致析构两次,产生段错误。就是本来已经析构了,但文件又被操作了一次。
3. 大文件传输
现象:可以传输小文件,但大文件怎么也传不上去。
结果:是我的打印日志问题。由于我习惯性会打印日志,因此在拆解 HTTP 报文报头时,我把 body 也给打印出来了。
但是,I/O 显示器比 I/O 文件慢很多。因此不是传不上去,而是日志要对显示器进行 I/O 导致传输很慢。
为什么 I/O 显示器比 I/O 文件慢?
显示器需要刷新和渲染,而文件就是 0 和 1 的二进制存储,不需要这些额外的开销。
总结
这个仿 muduo 服务器的接入层实现展示了:
-
Epoll 的核心机制:红黑树、就绪队列、回调函数
-
ET vs LT 模式:ET 通过倒逼程序员写出更高效的代码来提升性能
-
Reactor 模式:一个连接由指定的一个线程完成所有读写事件
-
核心类设计:Channel、Poller、TimeWheel、EventLoop、Connection、Acceptor、TcpServer 各司其职
-
项目亮点:
-
时钟计时(timerfd)
-
时间轮智能指针管理超时连接
-
eventfd 及时唤醒阻塞线程
-
Any 类的类型擦除实现
-
RunInLoop 保证任务在正确的线程执行
-
异步初始化的锁和条件变量控制
-
-
Debug 经验:读写回调混淆、命名错误导致的问题、大文件传输的日志陷阱
这些设计思想和实现细节对于理解高性能网络服务器的架构非常有价值。