7.11 侵入式程序结构与非侵入式程序结构
通常情况下,对于一个后端服务器的结构是没法固定下来的,因为我们不知道服务的技术框架上面会搭建什么业务,结构随着业务的不同而不同。如果是这样的话,我们是不是就没法讨论和抽象出一个可以适用于大多数业务场景的服务器程序结构了呢?其实也未必,大多数服务会与其他服务或者客户端进行网络通信,那么这个服务一定包含网络通信模块。这样的话,网络通信模块将会是不同业务服务的通用部分。基于这一点,我们可以继续讨论。
假设网络通信框架结构一定的情况下,根据通信数据是否从网络框架中流入流出,我们将服务器的结构分为侵入式和非侵入式两种。
# 7.11.1 非侵入式结构
非侵入式更简单一点,我们先来讨论它。所谓非侵入式,指的是一个服务中所有的通信或业务数据都在网络通信框架内部流动,也就是说没有外部数据源注入网络通信框架或从网络通信框架中流出。举个例子,一个 IM 服务的服务器,通常情况下,无论是单聊消息还是群聊消息,其核心业务本身的数据流都是在网络通信框架内部流动的。单聊时,A 用户给 B 用户发送一条消息,实际上消息流是从 A 用户的连接对象传递到 B 用户的连接对象上,然后通过 B 连接对象的发送方法发送出去。群聊也是一样,从一个用户的连接,同时发给其他多个用户的连接对象。无论是哪种情况,这些连接对象都是网络通信框架的内部结构。
# 7.11.2 侵入式结构
如果有外部消息流入网络通信模块或从网络通信模块流出,那么相当于有外部消息"侵入"了网络通信结构中,我们把这种服务器结构称之为侵入式服务结构。
侵入式服务器的结构除了网络通信组件外,其他组件的结构设计可以多种多样。我们来看两种通用结构:
结构一:业务线程(或者叫数据源线程)将数据处理后交给网络通信组件发送
结构二:网络解包后需要将任务交给专门的业务线程去处理,处理完后需要再次通过网络通信组件发出去
结构一其实是结构二的后半部分,因此我们来重点讨论下结构二。
在本章的前面几节,我们介绍了 one thread one loop 思想下的每个网络线程的基本结构:
while (!m_bQuitFlag)
{
epoll_or_select_func();
handle_io_events();
handle_other_things();
}
2
3
4
5
6
7
8
当 handle_io_events 收完网络数据后并解包,之后由于解包得到的任务处理逻辑比较耗时,此时我们需要把这些任务交给专门的业务线程去处理,业务线程可以是一组工作的消费者线程,我们可以将这些任务放在某个队列中,这样网络组件的线程(网络线程)是生产者,业务工作线程是消费者,我们可以使用互斥体、临界区(Windows)或条件变量等技术去协调生产者和消费者,这里也就是说会涉及到一个公共的队列系统。这是一种常用的实现,数据从网络组件流向其他组件(业务组件)。
接下来如果业务组件在处理完后,需要再次将处理后的数据进行网络通信,此时如何将处理后的数据由业务组件交给网络组件呢?这里一般有两种做法。
方法一
直接通过某些标识,如业务对应的 socket fd、sessionID 等找到这些数据对应的位于网络组件中 session 去将数据直接发出去。
例如,某个数据处理后需要发给所有用户,演示代码如下:
void WebSocketSessionManager::pushDataToAll(const std::string& dataToPush)
{
std::lock_guard<std::mutex> scoped_lock(m_mutexForSession);
for (auto& session : m_mapSessions)
session.second->pushSomeData(dataToPush);
}
2
3
4
5
6
上述代码中,dataToPush 是需要发给所有用户的数据,因此遍历网络组件中记录的所有 session 对象(Session 对象记录在 m_mapSessions 中)并挨个发送之。
如果处理后的数据是发给某个用户的,演示代码如下:
bool WebSocketSessionManager::pushDataToSingle(const std::string& accountID, const std::string& dataToPush)
{
std::lock_guard<std::mutex> scoped_lock(m_mutexForSession);
for (auto& session : m_mapSessions)
{
if (session.second->isAccountIDMatched(accountID))
{
session.second->pushSomeData(dataToPush);
return true;
}
}
return false;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
上述代码逻辑根据数据中的 accountID 是定位到具体的 session,然后将数据发送给该 session 对应的用户。
这种方法是业务组件处理完数据需要向网络线程传送数据常用的方法之一,但是这种方法存在如下两个缺点。
缺点一
这里从调用关系来看,实际上是业务线程调用网络线程相关的接口函数去发送数据的,也就是说,本质上是业务组件直接发起的网络发送数据操作。如果按功能来划分的话,发送数据应该属于网络线程的功能,业务线程不该去发送数据。由于 session 对象的所属是网络线程(网络线程管理着 session 对象的生命周期),而这里业务线程直接操作了 session 对象,因此上述演示代码中,使用了 mutex(成员变量 m_mutexForSession)在相应的发送函数中对 session 对象集合 m_mapSessions 进行保护。
这种方法的示意图如下:
这种方法虽然不太合理,但很多服务程序都这么做。当业务组件调用这些发送方法时,通过 mutex 将这些 session 锁定。但是这里存在一个效率问题,我们以上面向所有用户发送数据的示例为例:
void WebSocketSessionManager::pushDataToAll(const std::string& dataToPush)
{
std::lock_guard<std::mutex> scoped_lock(m_mutexForSession);
for (auto& session : m_mapSessions)
session.second->pushSomeData(dataToPush);
}
2
3
4
5
6
这段代码实际是调用每个 session 对象的 pushSomeData 方法(代码第 5 行),如果 session 对象的 pushSomeData 方法耗时比较长(耗时较长是相对来说的,实际开发中要避免让这个函数耗时过长),由于记录 session 对象 m_mapSessions 此时正被业务模块(业务线程)使用,因此网络线程如果想修改 m_mapSessions 对象,必须等业务线程调用 WebSocketSessionManager::pushDataToAll 函数结束,这可能会影响网络线程的执行效率。因此有些开发者会这么设计:
void WebSocketSessionManager::pushDataToAll(const std::string& dataToPush)
{
std::map<int64_t, BusinessSession*> mapLocalSessions;
{
std::lock_guard<std::mutex> scoped_lock(m_mutexForSession);
//从m_mapSessions拷贝session对象指针去mapLocalSessions
mapLocalSessions = m_mapSessions;
}
//这里使用 mapLocalSessions,让网络线程可以继续操作m_mapSessions
for (auto& session : mapLocalSessions)
session.second->pushSomeData(dataToPush);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
上述代码使用了一个临时变量 mapLocalSessions 复制了一份原来的 m_mapSessions 中记录的 session 指针,这样的话,m_mutexForSession 锁保护的范围就大幅度减小了,业务线程可以尽快释放 m_mapSessions ,网络线程可以很快使用 m_mapSessions。
然而,这个看似很不错的设计方案,却存在着严重的问题:记录在 m_mapSessions 和 mapLocalSessions 中的很多 session 指针都指向一个对象,倘若此时某个连接断开了,网络线程会销毁 m_mapSessions 中相应的 session 对象,然而业务线程还可能拿着这个 session 对象的指针去继续操作(发数据),此时这个指针已经是一个野指针了,这样会导致程序崩溃。有的读者会说,mapLocalSessions 中记录的 session 对象不要使用原始指针,可以使用一个智能指针,但该智能指针不管理该 session 的生命周期,如下面的形式:
std::map<int64_t, std::weak_ptr<BusinessSession>> mapLocalSessions;
这样虽然可以解决在使用一个 session 对象时及时发现该 session 对象是否有效的问题,但是倘若业务线程正在使用某个 session 对象的过程中,如进入 session.second->pushSomeData 函数后,session 被网络线程回收了,此时业务线程访问该 session 对象的任何成员变量都会因访问一个非法的指针导致程序崩溃。
所以,这是不正确的设计,在这种场景下,你千万不要使用这种减小锁的范围的技术。为了保证性能,应该尽量让 session.second->pushSomeData 函数的实现逻辑运行起来更快一点。
缺点二
缺点二是方法一在如下场景中的致命问题:假设你的服务存在如下两条信息流:
信息流一:业务组件产生的数据需要发送,网络线程本身与客户端或者下游服务交互后产生的数据也要发送,这两类数据如果发给同一个连接,但这两类数据有一定的顺序讲究。这下就糟糕了:因为这样的设计中,你的业务线程会间接使用某个 session 发数据,你的网络线程直接使用某个 session 发数据。这样相当于出现多线程同时调用 send 函数在同一个 socket 上发送了,这种情形下每个单独的数据包不一定会出错,但是多个数据包之间的顺序就出错了。
我曾经在开发某交易系统行情推送服务时就遇到过这样的问题,业务模块会从某个 kafka Topic 中取出增量数据,然后侵入网络组件发给用户,但用户本身会发订阅命令给网络模块,网络模块会自己通过一个内部的 http 服务去查询得到一个全量数据推送给用户。用户收到增量数据会以全量数据为基础,对全量数据进行增删改,也就是说如果用户未收到全量数据之前,会丢弃其收到的增量数据。但是被丢弃的增量数据可能是有效的,因为被丢弃,导致用户收到全量数据后再利用新的增量数据去改造全量数据,此时改造后的增量数据已经不正确了。
这就是方法一不适用的场景,即侵入到网络组件的其他组件产生的数据有多个源,且多个源的有顺序要求。反过来说,如果侵入到网络组件产生的数据源只有一个数据源或者有多个数据源但数据源之间的数据没有顺序依赖,这种设计也是可以的。
不适用的场景示意图
那么对于有多个数据源且数据源之间的数据有顺序依赖,有没有办法继续使用方法一呢?有的,你可以在多个数据源处理完数据后,交给一个专门将数据源排序的组件,再由排序组件统一调用网络组件的数据发送模块。需要注意的是,网络组件内部产生的需要发送的数据也要交给排序组件。示意图如下:
这种场景可以举一个例子,我们在做交易系统的行情推送服务时,由于推送的数据有多个来源,有的来源于 kafka 模块,有的来源于管理后台接口,有的来源于网络通信模块内部产生,最终这三类数据需要按一定的顺序发给用户,我就是采用上述示意图展示的结构来设计。其中排序组件使用了一个队列,不同数据源的数据按一定顺序进入队列中,排序组件从队列中挨个取出排好序的数据调用网络通信组件的数据发送模块来发送。
方法二
方法一是在业务组件里面直接调用网络组件的方法,有点越俎代庖之嫌。方法二是将业务组件需要发送的数据交给网络组件自己去发送。常用的实现方法是将对应的数据加入到数据所属的那个连接的网络线程中,再来看一眼这个结构:
while (!m_bQuitFlag)
{
epoll_or_select_func();
handle_io_events();
handle_other_things();
}
2
3
4
5
6
7
8
可以使用另外一个队列,业务组件将数据交给这个队列,然后告知对应的网络组件中的线程需要取任务执行。这个逻辑在前面介绍过了,即利用唤醒机制执行 handle_other_things 函数。
这里给出一种实现,业务组件调用
void EventLoop::runInLoop(const Functor& cb)
{
if (isInLoopThread())
{
cb();
}
else
{
queueInLoop(cb);
}
}
2
3
4
5
6
7
8
9
10
11
其中 cb 即需要执行的任务,由于业务线程和网络线程不是同一个线程,因此会执行 queueInLoop 函数,queueInLoop 的实现如下,这样任务被放到 pendingFunctors_ 容器中了,然后调用唤醒函数 wakeup()。
void EventLoop::queueInLoop(const Functor& cb)
{
{
std::unique_lock<std::mutex> lock(mutex_);
pendingFunctors_.push_back(cb);
}
if (!isInLoopThread() || doingOtherTasks_)
{
wakeup();
}
}
2
3
4
5
6
7
8
9
10
11
12
唤醒后的线程执行 handle_other_things() 函数,该函数从 pendingFunctors_ 中取出任务进行执行。
void EventLoop::handle_other_things()
{
std::vector<Functor> functors;
doingOtherTasks_ = true;
{
std::unique_lock<std::mutex> lock(mutex_);
functors.swap(pendingFunctors_);
}
size_t size = functors.size();
for (size_t i = 0; i < size; ++i)
{
functors[i](./);
}
doingOtherTasks_ = false;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
通过这个流程,让网络组件本身去发送了业务组件交给它的数据。
希望读者能深刻的体会侵入式和非侵入式服务结构的特点和细节,以及侵入式结构中网络组件与业务组件交换数据的两种方法,根据实际的业务设计出高质量的服务框架来。