12.添加可跨线程调用的函数,为了线程安全

本文详细介绍了如何在多线程环境下实现线程间的有效通信,特别是如何利用跨线程调用确保数据发送操作在正确的线程中执行,避免线程间数据竞争问题。

上一节我们添加了线程池,可以把一些比较耗时的任务放到线程池中处理。而我们处理完成后,需要把数据发送给回客户端,而上一节中,是在线程池的某一线程中把消息发送的,不是在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

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值