上一节我们添加了线程池,可以把一些比较耗时的任务放到线程池中处理。而我们处理完成后,需要把数据发送给回客户端,而上一节中,是在线程池的某一线程中把消息发送的,不是在IO线程发送。这样是有点问题,首先,这样就会有两个线程去控制该socket(IO线程会监听该socket的读写,而线程池又通过send()函数去调用该socket),这不符合我们的要求的。
一个socket只能在一个线程中进行操作,这样才不容易出问题。那么,我们该怎么解决这个问题呢。
1.跨线程调用,runInloop()函数
上一节在线程池中的线程调用了send(to_string(sum));那我们容易想到,要是把send(to_string(sum))转移到控制该socket的IO线程去,让该IO线程去调用send(to_string(sum))就好啦。
void onMessage(const ConnectionPtr& conn, Buffer* buf)
{
threadPool_.add([&conn, buf]() {
int nums = stoi(buf->retrieveAllAsString());
long long sum = 0;
for (int i = 0; i < nums; ++i)
sum += i;
conn->send(to_string(sum));
});
}
就是说可以进行跨线程调用,转移到控制该socket的IO线程去调用 send(to_string(sum))。
目前就主要是Connection::send(const string& msg)需要跨线程调用,那就先从该函数开始吧。
之前的Connection::send(const string& msg)
void Connection::send(const void* message, size_t len)
{
//省略很多........................
//如果当前channel没有写事件发生,并且发送缓冲区无待发送的数据,那就可以直接发送
if (!channel_->isWrite() && outputBuffer_.readableBytes() == 0) {
write(fd(), message, len);
}
}
那按照我们的想法,可以改写成如下的函数
void Connection::send(const void* message, size_t len)
{
if (state_ == StateE::kConnected) {
if (loop_->isInLoopThread()) {
sendInLoop(message, len);
}
else {
loop_->runInLoop([this]() {sendInLoop(message, len); });
}
}
}
思路:可以先判断当前调用Connection::send()的线程是否和loop_(控制该sokcet的IO线程,事件循环)对应的线程是否是一致的。
若一致就直接调用Connection::sendInloop(cosnt void*,len)函数,该函数和之前的Connection::send(const void* , size_t len)是一样的;若不一致,就把sendInLoop()函数转移到符合的IO线程(EventLoop线程)中。重点都在EventLoop类中。
首先在EventLoop类中添加些成员
public:
using Functor=std::function<void()>;//转移到EventLoop的回调函数(比如前面的sendInLoop()函数)
//判断该线程是否是该IO线程
bool isInLoopThread()const { return threadId_ == std::this_thread::get_id(); }
void runInLoop(Functor cb)
{
if (isInLoopThread()) {
cb();
}
else {
queueInLoop(std::move(cb));
}
}
void queueInLoop(Functor cb)
{
{
std::unique_lock<std::mutex> lock(mutex_); //这个锁和doPendingFunctors()函数中的锁是同一把锁,都是成员变量mutex_
pendingFunctors_.emplace_back(std::move(cb));
}
//....该函数还有部分代码,先省略.....
}
private:
std::atomic_bool callingPendingFunctors_; //标识当前loop是否有需要执行的回调操作
std::thread::id threadId_; //IO线程的线程id,在构造函数中使用std::this_thread::get_id()获得id
std::vector<Functor> pendingFunctors_; //存储loop需要执行的所有回调任务函数
std::mutex mutex_; // 互斥锁,用来保护上面vector容器的线程安全操作
通过runInLoop()函数去进行跨线程调用
该函数先判断此时刻的线程是否是该IO线程,若是就直接执行cb回调任务函数;若不是,就放到该eventloop中的任务队列来等待或立刻执行(通过queueInLoop(Functor cb)函数把任务回调函数存储到pendingFunctors_)。
那这时候把回调任务函数放到loop的任务队列中了,那是会怎样执行这回调任务函数呢。
2.修改后的EventLoop::loop()函数
void EventLoop::loop()
{
quit_ = false;
while (!quit_) {
activeChannels_.clear();
ep_->Epoll_wait(activeChannels_);
for (auto& active : activeChannels_) {
active->handleEvent();
}
////////////////////////////////////////////////////////////
//就是添加了这一句,执行当前EventLoop事件循环需要处理的回调任务操作
doPendingFunctors();
}
}
之前的loop()函数是不能实现执行回调任务的,现在添加了EventLoop::doPendingFunctors()函数,就可以通过该函数去执行回调任务(即是前面的sendInLoop())。
3.doPendingFunctors()的实现
该函数不是简单地在临界区内依次调用Funcotr,而是把回调列表swap()到局部变量functors中,这样一方面可以减小了临界区的长度(意味着不会阻塞其他线程调用queueInLoop()),另一方面也避免了死锁(因为Funcor也可能会再调用queueInLoop());
//执行任务回调函数
void EventLoop::doPendingFunctors()
{
std::vector<Functor> functors;
callingPendingFunctors_ = true; //标识当前loop是有需要执行的回调操作
// 把functors转移到局部的functors,这样在执行回调时不用加锁。不影响loop注册回调任务
{
std::unique_lock<std::mutex> lock(mutex_);//这个锁和queueInLoop()函数中的锁是同一把锁,都是成员变量mutex_
functors.swap(pendingFunctors_);
}
for (const auto& functor : functors) {
functor();//执行当前loop需要执行的回调操作
}
callingPendingFunctors_ = false;
}
//若只是简单地在临界区内依次调用Functor,那实现如下
void BaddoPendingFunctors()
{
std::unique_lock<std::mutex> lock(mutex_);
for (const auto& functor : functors) {
functor();//执行当前loop需要执行的回调操作
}
}
这里避免死锁需要展开说说,就像BaddoPendingFunctors()函数这样。 functor执行的时候,是获得锁mutex_的,而 functor()也有可能再调用queueInLoop(cb)。
而在调用queueInLoop()过程中,也需要获得锁mutex_(看queueInLoop()代码的实现),而functor()把锁持有了,那queueInLoop()就不能获得这把锁,那么functor()也就卡住了不能继续执行下去,所以就会出现死锁。
4.eventfd,唤醒线程作用
现在是可以执行回调任务了,那问题也来了:执行的时机是否是比较及时的呢?
从loop()函数中可知,该函数没有事件触发的时候会阻塞在ep_->Epoll_wait(activeChannels_)这里,也即是阻塞在epoll_wait()函数中。那这样回调任务就需要触发了某事件才能去执行,不然就在干等着,那这可不行。所以在往loop中注册回调任务函数的时候,就要触发epoll_wait()。
eventfd
Linux 2.6.27后添加了一个新的特性,就是eventfd,是用来实现多进程或多线程的之间的事件通知的,也可以由内核通知用户空间应用程序事件。eventfd的使用是先要调用::eventfd()函数进行创建,和创建一个sockfd相似。
再来看看Eventloop类需要添加的新成员
public:
void wakeup();
private:
void handleRead(); //用于响应wake up
int wakeupFd_;
std::unique_ptr<Channel> wakeupChannel_;
来看看EventLoop的构造函数,wakeupFd_也和其他的普通socketfd一样,也会有个channel,也会设置读回调函数,epoll会监听该channel的读事件。
// 创建wakeupfd,用来notify唤醒处理回调任务操作
int createEventfd()
{
int evtfd = ::eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC);
return evtfd;
}
EventLoop::EventLoop()
:threadId_(std::this_thread::get_id())
,quit_(false)
,callingPendingFunctors_(false)
,ep_(std::make_unique<Epoll>())
,wakeupFd_(createEventfd())
,wakeupChannel_(std::make_unique<Channel>(this,wakeupFd_))
{
// 设置wakeupfd的发生事件后的回调操作
wakeupChannel_->SetReadCallback([this]() {handleRead(); });
//监听wakeupchannel_的EPOLLIN读事件了
wakeupChannel_->enableReading();
}
那怎样操作wakeupFd_才能唤醒epoll_wait()呢,这个很容易,和其他socketfd差不多。在wakeup()函数中
// 用来唤醒loop所在线程 向wakeupfd_写一个数据,wakeupChannel就发生读事件,当前loop线程就会被唤醒
void EventLoop::wakeup()
{
uint64_t one = 1;
ssize_t n = write(wakeupFd_, &one, sizeof(one));
if (n != sizeof(one)){
printf("EventLoop wakeup write %lu bytes instead of 8 \n", n);
}
}
5.调用wakeup()函数的时刻
这时就需要再回头看看EventLoop::queueInLoop(Functor cb)函数了。该函数是把任务回调函数存储到loop所在的pendingFunctors_中,并在必要时唤醒loop,即是调用wakeup()。
void EventLoop::queueInLoop(Functor cb)
{
{
//这里为什么要加锁呢,因为有其他线程也会使用pendingFunctors_,
//EventLoop要从pendingFunctors_中拿任务出来执行,而可能线程池中的线程也同时往pendingFunctors_中添加任务,所以需要加锁来进行同步
std::unique_lock<std::mutex> lock(mutex_);
pendingFunctors_.emplace_back(std::move(cb));
}
if (!isInLoopThread() || callingPendingFunctors_) {
wakeup();
}
}
"必要时"有两种情况,如果调用queueInLoop()的不是IO线程,那么唤醒是必须的;如果在IO线程调用queueInLoop(),而如果此时正在调用pending functor(即是callingPendingFunctors_为ture),那么也是必须要唤醒的。
换句话说,就是只有在IO线程的事件回调中(即是active->handleEvent())调用queueInLoop()才无需wakeup()。认真看doPendingFunctors()的调用时间点就可以明白,执行完handleEvent(),就会跟着执行doPendingFunctors()。
那么又有问题来了哈:为什么不把doPendingFunctors()函数放到EventLoop::handleRead()内呢,不是说把任务回调函数存储到loop线程中了,就要及时唤醒线程去执行回调函数呢?
那就先说把doPendingFunctors()函数放到EventLoop::handleRead()内的情况会是怎样的,那就是每次要执行doPendingFunctors()都要执行wakeup(),这样就必须经过write->epoll_wait->read三个系统调用操作,这相比就很不划算。
而若是在 loop() 的 while 循环中直接调用,那么,有可能并不需要 wakeup() 就可以将这些新增的 cb任务回调函数执行完。基本就是这个原因了。
6.跨线程执行的顺序

1.send()中要调用runInLoop()函数。
2.runInLoop()函数中,若不是同一线程,就需要调用queueInLoop()函数。
3.queueInLoop()函数中把任务回调函数存储好,必要时(两个条件中的任一个成立)调用wakeup()。
4.需要调用handleRead()去响应wakeup()函数的唤醒,不然epoll_wait()就会一直触发的。
5.若有IO线程的事件回调函数就执行(即是active->handleEvent());执行完了,就执行doPendingFunctors()。
6.这是回到该IO线程了,说明在同一线程了,就调用Connection::sendInLoop(),就可以发送了。
这样跨线程调用就讲解完了,中间的过程是有点多,但是很重要,我们这样通过基本不用锁就进行了跨线程调用,muduo的这个跨线程调用的想法确实是很巧妙。这节的内容是很重要的,要好好理解。带着问题去思考,这样是应该比较好理解的,一步一步地把一些困惑解决掉哈。
完整源代码:https://github.com/liwook/CPPServer/tree/main/code/server_v12
本文详细介绍了如何在多线程环境下实现线程间的有效通信,特别是如何利用跨线程调用确保数据发送操作在正确的线程中执行,避免线程间数据竞争问题。
1136

被折叠的 条评论
为什么被折叠?



