一、RPC的网络IO模型
1、连接独占线程或进程: 在这个模型中,线程/进程处理来自绑定连接的消息,在连接断开前不退也不做其他事情。当连接数逐渐增多时,线程/进程占用的资源和上下文切换成本会越来越大,性能很差,这就是C10K问题的来源。这种方法常见于早期的web server,现在很少使用。
2、单线程reactor: 以libevent, libev等event-loop库为典型。这个模型一般由一个event dispatcher等待各类事件,待事件发生后原地调用对应的event handler,全部调用完后等待更多事件,故为"loop"。这个模型的实质是把多段逻辑按事件触发顺序交织在一个系统线程中。一个event-loop只能使用一个核,故此类程序要么是IO-bound,要么是每个handler有确定的较短的运行时间(比如http server),否则一个耗时漫长的回调就会卡住整个程序,产生高延时。在实践中这类程序不适合多开发者参与,一个人写了阻塞代码可能就会拖慢其他代码的响应。由于event handler不会同时运行,不太会产生复杂的race condition,一些代码不需要锁。此类程序主要靠部署更多进程增加扩展性。单线程reactor的运行方式及问题如下图所示:
3、N:1线程库: 又称为Fiber,以GNU Pth, StateThreads等为典型,一般是把N个用户线程映射入一个系统线程。同时只运行一个用户线程,调用阻塞函数时才会切换至其他用户线程。N:1线程库与单线程reactor在能力上等价,但事件回调被替换为了上下文(栈,寄存器,signals),运行回调变成了跳转至上下文。和event loop库一样,单个N:1线程库无法充分发挥多核性能,只适合一些特定的程序。只有一个系统线程对CPU cache较为友好,加上舍弃对signal mask的支持的话,用户线程间的上下文切换可以很快(100~200ns)。N:1线程库的性能一般和event loop库差不多,扩展性也主要靠多进程。
4、多线程reactor: 以boost::asio为典型。一般由一个或多个线程分别运行event dispatcher,待事件发生后把event handler交给一个worker线程执行。 这个模型是单线程reactor的自然扩展,可以利用多核。由于共用地址空间使得线程间交互变得廉价,worker thread间一般会更及时地均衡负载,而多进程一般依赖更前端的服务来分割流量,一个设计良好的多线程reactor程序往往能比同一台机器上的多个单线程reactor进程更均匀地使用不同核心。不过由于cache一致性的限制,多线程reactor并不能获得线性于核心数的性能,在特定的场景中,粗糙的多线程reactor实现跑在24核上甚至没有精致的单线程reactor实现跑在1个核上快。由于多线程reactor包含多个worker线程,单个event handler阻塞未必会延缓其他handler,所以event handler未必得非阻塞,除非所有的worker线程都被阻塞才会影响到整体进展。事实上,大部分RPC框架都使用了这个模型,且回调中常有阻塞部分,比如同步等待访问下游的RPC返回。
多线程reactor的运行方式及问题如下:
5、M:N线程库: 即把M个用户线程映射入N个系统线程。M:N线程库可以决定一段代码何时开始在哪运行,并何时结束,相比多线程reactor在调度上具备更多的灵活度。但实现全功能的M:N线程库是困难的,它一直是个活跃的研究话题。我们这里说的M:N线程库特别针对编写网络服务,在这一前提下一些需求可以简化,比如没有时间片抢占,没有(完备的)优先级等。M:N线程库可以在用户态也可以在内核中实现,用户态的实现以新语言为主,比如GHC threads和goroutine,这些语言可以围绕线程库设计全新的关键字并拦截所有相关的API。而在现有语言中的实现往往得修改内核,比如Windows UMS和google SwitchTo(虽然是1:1,但基于它可以实现M:N的效果)。相比N:1线程库,M:N线程库在使用上更类似于系统线程,需要用锁或消息传递保证代码的线程安全。
多核扩展性
-
理论上代码都写成事件驱动型能最大化reactor模型的能力,但实际由于编码难度和可维护性,用户的使用方式大都是混合的:回调中往往会发起同步操作,阻塞住worker线程使其无法处理其他请求。一个请求往往要经过几十个服务,线程把大量时间花在了等待下游请求上,用户得开几百个线程以维持足够的吞吐,这造成了高强度的调度开销,并降低了TLS相关代码的效率。任务的分发大都是使用全局mutex + condition保护的队列,当所有线程都在争抢时,效率显然好不到哪去。更好的办法也许是使用更多的任务队列,并调整调度算法以减少全局竞争。比如每个系统线程有独立的runqueue,由一个或多个scheduler把用户线程分发到不同的runqueue,每个系统线程优先运行自己runqueue中的用户线程,然后再考虑其他线程的runqueue。这当然更复杂,但比全局mutex + condition有更好的扩展性。这种结构也更容易支持NUMA。
-
当event dispatcher把任务递给worker线程时,用户逻辑很可能从一个核心跳到另一个核心,并等待相应的cacheline同步过来,并不很快。如果worker的逻辑能直接运行于event dispatcher所在的核心上就好了,因为大部分时候尽快运行worker的优先级高于获取新事件。类似的是收到response后最好在当前核心唤醒正在同步等待RPC的线程。
异步编程
-
异步编程中的流程控制对于专家也充满了陷阱。任何挂起操作,如sleep一会儿或等待某事完成,都意味着用户需要显式地保存状态,并在回调函数中恢复状态。异步代码往往得写成状态机的形式。当挂起较少时,这有点麻烦,但还是可把握的。问题在于一旦挂起发生在条件判断、循环、子函数中,写出这样的状态机并能被很多人理解和维护,几乎是不可能的,而这在分布式系统中又很常见,因为一个节点往往要与多个节点同时交互。另外如果唤醒可由多种事件触发(比如fd有数据或超时了),挂起和恢复的过程容易出现race condition,对多线程编码能力要求很高。语法糖(比如lambda)可以让编码不那么“麻烦”,但无法降低难度。
-
共享指针在异步编程中很普遍,这看似方便,但也使内存的ownership变得难以捉摸,如果内存泄漏了,很难定位哪里没有释放;如果segment fault了,也不知道哪里多释放了一下。大量使用引用计数的用户代码很难控制代码质量,容易长期在内存问题上耗费时间。如果引用计数还需要手动维护,保持质量就更难了,维护者也不会愿意改进。没有上下文会使得RAII无法充分发挥作用, 有时需要在callback之外lock,callback之内unlock,实践中很容易出错。
二、RPC的线程池模型(网络和业务)
1、线程池的作用: 线程使应用能够更加充分合理的协调利用cpu 、内存、网络、i/o等系统资源。线程的创建需要开辟虚拟机栈,本地方法栈、程序计数器等线程私有的内存空间。在线程的销毁时需要回收这些系统资源。频繁的创建和销毁线程会浪费大量的系统资源,增加并发编程的风险。另外,在服务器负载过大的时候,如何让新的线程等待或者友好的拒绝服务?这些丢失线程自身无法解决的。所以需要通过线程池协调多个线程,并实现类似主次线程隔离、定时执行、周期执行等任务。线程池的作用包括:
- 降低资源消耗:通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度:当任务到达时,可以不需要等待线程创建就能立即执行。
- 提高线程的可管理性:线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,监控和调优。利用线程池管理并复用线程、控制最大并发数等。
2、线程池大小的确定: 线程池大小确定有一个简单且使用面比较广的公式:
- CPU密集型任务(N+1): 这种任务消耗的主要是CPU资源,可以将线程数设置为N(CPU核心数)+1,比CPU核心数多出来一个线程是为了防止线程偶发的缺页中断,或者其他原因导致的任务暂停而带来的影响。一旦任务停止,CPU就会出于空闲状态,而这种情况下多出来一个线程就可以充分利用CPU的空闲时间。
- I/O密集型(2N): 这种任务应用起来,系统大部分时间用来处理I/O交互,而线程在处理I/O的是时间段内不会占用CPU来处理,这时就可以将CPU交出给其他线程使用。因此在I/O密集型任务的应用中,可以配置多一些线程,具体计算方是2N。
3、网络线程池:tars网络模型多线程reactor:
网络IO处理:
-
避免惊群(新版Linux内核已优化惊群),tars网络线程中只有第一个网络线程(NetThread)持有监听套接字。虽然所有的连接请求都是通过负责监听的网络线程接入,但新建立的请求连接可以通过TC_EpollServer统筹分配给其他的网络线程进行管理。初始化时,监听socket注册进相应网络线程的epoll结构中,监听事件为EPOLLIN,data类型为ET_LISTEN(有数据可读也是EPOLLIN事件,用data区分是何种类型的EPOLLIN消息)。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll的事件注册函数,它不同与select()是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。第一个参数是epoll_create()的返回值,第二个参数表示动作,用三个宏来表示:EPOLL_CTL_ADD:注册新的fd到epfd中;
EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
EPOLL_CTL_DEL:从epfd中删除一个fd;
struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
typedef union epoll_data {
void *ptr;
int fd;
__uint32_t u32;
__uint64_t u64;
} epoll_data_t;
-
NetThread网络线程主循环中的不断阻塞在epoll_wait监听返回的各类事件。当有ET_LISTEN消息时,调用网络线程的accept方法接受连接请求,accept方法内获取新请求连接的fd、ip、port等信息,构建一个connection结构,并把connection分配给工作线程。后面的所有连接相关的读写关闭等操作均通过该结构来完成。
void TC_EpollServer::NetThread::run() { //循环监听网路连接请求 while(!_bTerminate) { _list.checkTimeout(TNOW); int iEvNum = _epoller.wait(2000); for(int i = 0; i < iEvNum; ++i) { try { const epoll_event &ev = _epoller.get(i); uint32_t h = ev.data.u64 >> 32; switch(h) { case ET_LISTEN: { //监听端口有请求 auto it = _listeners.find(ev.data.u32); if( it != _listeners.end()) { if(ev.events & EPOLLIN) { bool ret; do { ret = accept(ev.data.u32, it->second->_ep.isIPv6() ? AF_INET6 : AF_INET); }while(ret); } } } break; case ET_CLOSE: //关闭请求 break; case ET_NOTIFY: //发送通知 processPipe(); break; case ET_NET: //网络请求 processNet(ev); break; default: assert(true); } } catch(exception &ex) { error("run exception:" + string(ex.what())); } } } }
-
新建的connection回传给TC_EpollServer结构进行分配(TC_EpollServer是tarsServer部分的统筹管理者,管理Server持有的资源,包括网络线程、处理线程等等)。TC_EpollServer通过简单的负载均衡(连接的fd值对网络线程取模)获取一个网络线程,将connection指派给该线程进行管理。被指派的网络线程将新的conneciton加入自己的connectionlist中进行维护 (一个连接链表,包括超时踢除等等逻辑, 每个网络线程都有一个独立的链接列表ConnectionList,负责管理该网络线程的所有Connection,ConnectionList继承于TC_ThreadLock,是线程安全的),同时将这个连接fd注册进网络线程的epoll中监听EPOLLIN和EPOLLOUT事件(epoll的data是连接的uid)。EPOLLIN和EPOLLOUT分别表征该连接fd有数据写入可读及可写。
//监听主线程通过简单的负载均衡(连接的fd值对网络线程取模)获取一个网络线程
NetThread* getNetThreadOfFd(int fd)
{
return _netThreads[fd % _netThreads.size()];
}
//TC_EpollServer通过简单的负载均衡(连接的fd值对网络线程取模)获取一个网络线程,将connection指派给该线程进行管理,每个线程都有一个connectList列表对链接进行管理
void TC_EpollServer::addConnection(TC_EpollServer::NetThread::Connection * cPtr, int fd, int iType)
{
TC_EpollServer::NetThread* netThread = getNetThreadOfFd(fd);
if(iType == 0)
{
netThread->addTcpConnection(cPtr);
}
else
{
netThread->addUdpConnection(cPtr);
}
}
//将这个连接fd注册进网络线程的epoll中监听EPOLLIN和EPOLLOUT事件
void TC_EpollServer::NetThread::addTcpConnection(TC_EpollServer::NetThread::Connection *cPtr)
{
uint32_t uid = _list.getUniqId();
cPtr->init(uid);
_list.add(cPtr, cPtr->getTimeout() + TNOW);
cPtr->getBindAdapter()->increaseNowConnection();
...
//注意epoll add必须放在最后, 否则可能导致执行完, 才调用上面语句
_epoller.add(cPtr->getfd(), cPtr->getId(), EPOLLIN | EPOLLOUT);
}
4、当网络线程netthread解析出来一个完整的请求包时,会调用netthread内的适配器BindAdapter变量进行任务插入,即将任务插入到线程安全的队列中typedef TC_ThreadQueue<tagRecvData*, deque<tagRecvData*> > recv_queue;
void TC_EpollServer::NetThread::Connection::insertRecvQueue(recv_queue::queue_type &vRecvData)
{
if(!vRecvData.empty())
{
int iRet = _pBindAdapter->isOverloadorDiscard();
if(iRet == 0)//未过载
{
_pBindAdapter->insertRecvQueue(vRecvData);
}
else if(iRet == -1)//超过接受队列长度的一半,需要进行overload处理
{
recv_queue::queue_type::iterator it = vRecvData.begin();
recv_queue::queue_type::iterator itEnd = vRecvData.end();
while(it != itEnd)
{
(*it)->isOverload = true;
++it;
}
_pBindAdapter->insertRecvQueue(vRecvData,false);
}
else//接受队列满,需要丢弃
{
recv_queue::queue_type::iterator it = vRecvData.begin();
recv_queue::queue_type::iterator itEnd = vRecvData.end();
while(it != itEnd)
{
delete (*it);
++it;
}
}
}
}
//每个bindadapter关联了一个线程安全的接收队列
//BindAdapter的队列插入操作。
void TC_EpollServer::BindAdapter::insertRecvQueue(const recv_queue::queue_type &vtRecvData, bool bPushBack)
{
{
if (bPushBack)
{
_rbuffer.push_back(vtRecvData);
}
else
{
_rbuffer.push_front(vtRecvData);
}
}
TC_ThreadLock::Lock lock(_handleGroup->monitor);
_handleGroup->monitor.notify();
}
5、具体servantHandle业务线程:每个bindadpater关联一个handlegroup,每个handlegroup中包含了多个handle线程。class Handle : public TC_Thread
每个handle继承了tc_thread可以理解为为一个单独的工作线程。handle线程中调用servantHandle的处理逻辑,即先根据tars协议解包,并调用servant的dispatch函数进行业务的分发处理。
//一个BindAdapter唯一对应一个Servant,也唯一对应一个HandleGroup成员变量,和一个接收队列成员变量
//一个handlegroup 有多个handle,一个handlegroup可以对应多个bindadpter.
//但是一个HandleGroup可以同时为多个BindAdapter服务。BindAdapter唯一对应Server的一个Obj,也唯一对应一个EndPoint(Ip-Port)。
struct HandleGroup : public TC_HandleBase
{
string name;
TC_ThreadLock monitor;
vector<HandlePtr> handles;
map<string, BindAdapterPtr> adapters;
};
//bindadapter关联的队列任务弹出操作。
bool TC_EpollServer::BindAdapter::waitForRecvQueue(tagRecvData* &recv, uint32_t iWaitTime)
{
bool bRet = false;
bRet = _rbuffer.pop_front(recv, iWaitTime);
if(!bRet)
{
return bRet;
}
return bRet;
}
//一个handle中的处理逻辑
void TC_EpollServer::Handle::handleImp()
{
startHandle();
while (!getEpollServer()->isTerminate())
{
...
//上报心跳
heartbeat();
//为了实现所有主逻辑的单线程化,在每次循环中给业务处理自有消息的机会
handleAsyncResponse();
handleCustomMessage(true);
tagRecvData* recv = NULL;
map<string, BindAdapterPtr>& adapters = _handleGroup->adapters;
for (auto& kv : adapters)
{
BindAdapterPtr& adapter = kv.second;
try
{
while (adapter->waitForRecvQueue(recv, 0))
{
//上报心跳
heartbeat();
//为了实现所有主逻辑的单线程化,在每次循环中给业务处理自有消息的机会
handleAsyncResponse();
tagRecvData& stRecvData = *recv;
int64_t now = TNOWMS;
stRecvData.adapter = adapter;
//数据已超载 overload
if (stRecvData.isOverload)
{
handleOverload(stRecvData);
}
//关闭连接的通知消息
else if (stRecvData.isClosed)
{
handleClose(stRecvData);
}
//数据在队列中已经超时了
else if ( (now - stRecvData.recvTimeStamp) > (int64_t)adapter->getQueueTimeout())
{
handleTimeout(stRecvData);
}
else
{
handle(stRecvData);
}
handleCustomMessage(false);
delete recv;
recv = NULL;
}
}
...
}
stopHandle();
}
//handle中调用servanthandle的处理逻辑
void ServantHandle::handleTarsProtocol(const TarsCurrentPtr ¤t)
{
...
map<string, ServantPtr>::iterator sit = _servants.find(current->getServantName());
if (sit == _servants.end())
{
current->sendResponse(TARSSERVERNOSERVANTERR);
#ifdef _USE_OPENTRACKING
finishTracking(TARSSERVERNOSERVANTERR, current);
#endif
return;
}
int ret = TARSSERVERUNKNOWNERR;
string sResultDesc = "";
vector<char> buffer;
try
{
//业务逻辑处理
ret = sit->second->dispatch(current, buffer);
}
...
//单向调用或者业务不需要同步返回
if (current->isResponse())
{
current->sendResponse(ret, buffer, TarsCurrent::TARS_STATUS(), sResultDesc);
}
#ifdef _USE_OPENTRACKING
finishTracking(ret, current);
#endif
}
6、业务线程池: 根据配置好的线程数目提前创建好一定数目的工作线程数,每个线程都在一个线程安全队列中获取执行任务,进行处理。
void TC_ThreadPool::ThreadWorker::run()
{
//调用处理部分
while (!_bTerminate)
{
auto pfw = _tpool->get(this);
if(pfw)
{
try
{
pfw();
}
catch ( ... )
{
}
_tpool->idle(this);
}
}
//结束
_tpool->exit();
}