一种轻量的C++无锁多线程框架

上一篇《利用std::atomic实现无锁队列-CSDN博客》已经讲清楚了如何实现各种情况下的无锁队列,那么无锁的多线程框架该提上日程。

c++11后,多线程编程已经变得比较简单了,运行时开启一个线程的代价也不是很大,但是语言本身提供的功能还是比较像原料,在复杂的业务需求下,我们需要形成一个多线程的编程框架。

通常情况下,程序中的线程都会赋予某些固定的角色,比如《一个简单易用的轻量化的c++网络库-CSDN博客》中,网络线程被赋予的特定的角色,并且持有自己需要管理的数据(网络连接),又比如游戏服务器中经常需要把某些功能或者某些数据交给固定的线程处理;线程除了被赋予特定角色外,他们执行的任务也可能具有不确定性,比如外部提交进来一个std::function。因此有必要对线程进行封装,让他们有自己的名称、持有自己管理的数据、能接受各种任务、能自省(在调用栈的任何位置,能识别自身),让线程变成一个不需要休息的工作者,有活就爬起来干,没有活就一边呆着;相对于单个线程,线程池应设计成竞争式的,即任务到来时,唤醒一个不繁忙的线程来完成工作。

线程的任务队列

按照上述的思路,我们封装的线程对象中,应有自己的一个无锁队列,用于接收外部提交的任务:

TMultiV1Queue<std::function<void()> > mTaskQueue;

这是一个多对一线程安全的队列,主要接收封装好的std::function任务。我们还需要一个让线程休眠和唤醒的事件,我们可以使用std::condition_variable:

void ThreadEvent::Wait(uint32_t uMillisec) {
  std::unique_lock<std::mutex> lck(mMutex);
  if (!mSignal) mCond.wait_for(lck, std::chrono::milliseconds(uMillisec));
  mSignal = false;
}

void ThreadEvent::WaitInfinite() {
  std::unique_lock<std::mutex> lck(mMutex);
  if (!mSignal) mCond.wait(lck);
  mSignal = false;
}

void ThreadEvent::Post() {
  std::unique_lock<std::mutex> lck(mMutex);
  mSignal = true;
  mCond.notify_one();
}

我们向一个线程提交任务就可以写成这样:

void Thread::Post(const std::function<void()>& func) {
   if (!func) return;
   mTaskQueue.Enqueue(func);
   mEvent.Post();
}

线程在自己的主循环中不断去消费队列中的std::function,完成外部提交的任务:

 int32_t Thread::RunTask() {
  int32_t nCount = 0;
  while (true) {
    std::function<void()> func;
    if (!mTaskQueue.Dequeue(func)) break;
    func();
    nCount++;
  }
  return nCount;
 }

在外层线程的主循环中,我们不断调用RunTask函数即可,无任务的时候线程会挂起,有任务的时候线程唤醒进行工作。

while (pThread->mRunningFlag > 0) {
  pThread->RunTask();
  pThread->mEvent.WaitInfinite();
}

让线程自省

我们需要在程序中任何部位都可以获得当前线程的信息,使用std::thread提供的能力是不太够的,如果把线程id做key,然后线程对象存储在全局的map中,又可能有多线程访问的问题,这种方式实现起来太过不美观,我们需要借助线程的私有数据机制实现线程自省(linux下是线程私有数据,windows下是Tls,他们有细微的差别),我们把线程的指针存储在线程的私有数据中,在程序的任何地方都可以从线程私有数据中得到自己的指针。核心部分代码如下:

bool ThreadData::Set(void* pData) {
  void* pOldData = Get();
  if (pData == pOldData) return true;
  if (pOldData != nullptr && mDestructor != nullptr) {
    mDestructor(pOldData);
  }
#if CPPFD_PLATFORM == CPPFD_PLATFORM_WIN32
  if (mDestructor && pData != nullptr) {
    std::lock_guard<std::mutex> lock(mLocker);
    mDataPtrList.push_back(pData);
  }
  int nRet = TlsSetValue(mKey, pData);
  nRet = (nRet != 0) ? 0 : GetLastError();
  return nRet == 0;
#else
  return pthread_setspecific(mKey, pData) == 0;
#endif
}

void* ThreadData::Get() {
#if CPPFD_PLATFORM == CPPFD_PLATFORM_WIN32
  void* pData = TlsGetValue(mKey);
  if (pData == nullptr && GetLastError() != ERROR_SUCCESS) {
    return nullptr;
  }
  return pData;
#else
  return pthread_getspecific(mKey);
#endif
}

我们定义一个静态的ThreadData变量,在线程启动时,存储当前线程的指针即可。

void Thread::Start(const String& strName) {
  mName = strName;
  mThread = std::thread(Routine, this);
 }

void Thread::Routine(Thread* pThread) noexcept {
  if (pThread == nullptr) return;
  sThreadData.Set(pThread);  //线程自省,需要的地方
  srand((uint32_t)time(nullptr)); //让每次启动的随机情况都不一样

  ……
}

FORCEINLINE static Thread* GetCurrentThread() { return (Thread*)sThreadData.Get(); }

我们可以很方便地在任何地方获得当前的线程指针,比如在日志输出时,我们可以很方便地知道,这条日志是哪个线程输出的。

线程间的任务交互模式

上述设计里面,我们提供了一种异步的任务提交模式,通过TMultiV1Queue<std::function<void()> >提交std::function即可,我们还需要两种基本模式,Invoke和Async

Inoke是需要等待返回结果的(类似std::future),我们可以很方便地通过上述的ThreadEvent来实现:

template<typename R>
R Invoke(const std::function<R()>& func) {
  Thread* pCurrThread = GetCurrentThread();
  if (pCurrThread == this) return func(); //自己给自己invoke,需要立即执行,避免死锁
  R res;
  ThreadEvent ev(false);
  Post([&]() {
    res = func();
    ev.Post();
  });
  ev.WaitInfinite();
  return res;
}

void Invoke(const std::function<void()>& func) {
  Thread* pCurrThread = GetCurrentThread();
  if (pCurrThread == this) {
    func();
    return;
  }
  ThreadEvent ev(false);
  Post([&]() {
    func();
    ev.Post();
  });
  ev.WaitInfinite();
}

Async是两个线程之间相互异步操作,线程A给线程B提交一个任务,线程B做完后,让线程A执行回调。这里面我们需要注意的是,线程B任务完成后,线程A如果被释放了,回调会导致程序崩溃,因此我们需要一个保护机制,确保线程A执行回调的时候指针是有效的。限于篇幅,我就不展开了,可以去文章最后的开源库中看关于线程的部分。

void Thread::Async(const std::function<void()>& func, const std::function<void()>& cbFunc) {
    std::shared_ptr<ThreadKeeper> pKeeper = GetCurrentThreadKeeper();
    if (pKeeper.get() == nullptr) { //当前不是cppfd:Thread线程
      Invoke(func);
      cbFunc();
      return;
    }
    Post([=]() { 
      func();
      Thread* pCallThread = pKeeper->Keep();  //确保回调的线程没被回收
      if (pCallThread != nullptr) pCallThread->Post(cbFunc); 
      pKeeper->Release();
    });
}

线程池

为了线程对象的统一性,我们在线程池中也需要使用cppfd::Thread,这样才能保证每个线程都是能自省的。线程池最好是一个竞争性的设计,任务队列需要使用多对多无锁的结构

TQueue<std::function<void()> > mTaskQueue;

对于新到来的任务,我们还是需要std::condition_variable变量来唤醒线程,我们只需要对线程进行一些改造就可以达到线程与线程池互相不冲突,每个线程只需要知道自己工作在什么模式下:

void Thread::Routine(Thread* pThread) noexcept {
  if (pThread == nullptr) return;
  sThreadData.Set(pThread);
  srand((uint32_t)time(nullptr));

  if (pThread->mPool == nullptr){ //独立工作模式,消费自己的队列
      while (pThread->mRunningFlag > 0) {
        pThread->RunTask();
        pThread->mEvent.WaitInfinite();
      }
  } else {   //线程池的成员,消费公共的任务队列为主
      while (pThread->mPool->mRunningFlag > 0) {
        {
          std::unique_lock<std::mutex> lck(pThread->mPool->mQueueLock);
          pThread->mPool->mQueueReady.wait(lck, [=]() { return pThread->mPool->mRunningFlag <= 0 || !pThread->mPool->mTaskQueue.IsEmpty() || pThread->mPool->mBroadCastNum > 0; });
        }
        pThread->mPool->RunTask();
        pThread->mPool->mBroadCastNum -= pThread->RunTask(); //自己的队列变成广播型任务
      }
  }
}

线程池模式下,线程消费公共队列里的任务,并把自己的私有的任务队列变成广播任务队列,即只有向线程池所有的线程广播任务时,才会把任务写入各个线程的私有队列

void ThreadPool::BroadCast(const std::function<void()>& func) {
  for (int32_t i = 0; i < mThreadNum; i++) {
    mBroadCastNum++;
    mThreads[i].mTaskQueue.Enqueue(func);
  }
  {
    std::unique_lock<std::mutex> lck(mQueueLock);
    mQueueReady.notify_all();
  }
 }

至此,无锁线程框架之所以是无锁,是因使用的任务队列是无锁的,详细代码可查看开源库:GitHub - laiyongcong/cppfoundation: c++ basic library

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值