本框架实现了一套协程同步原语来解决原生同步原语带来的阻塞问题,在协程同步原语之上实现更高层次的抽象——Channel用于协程之间的便捷通信,本文简单介绍一下如何设计。
我们都知道,一旦协程阻塞后整个协程所在的线程都将阻塞,这也就失去了协程的优势。编写协程程序时难免会对一些数据进行同步,而Linux下常见的同步原语互斥量、条件变量、信号量等基本都会堵塞整个线程,使用原生同步原语协程性能将大幅下降,甚至发生死锁的概率大大增加!
只有重新实现一套用户态协程同步原语才能解决这个问题。
在开始实现之前我们先简单介绍一下原理。原生同步对象由内核维护,当互斥量获取锁失败,条件变量wait,信号量wait获取失败时,内核将条件不满足的线程加入一个由内核维护的等待队列,然后阻塞线程,等待条件满足时将线程重新加入调度。
如同协程之于线程,我们很容易得到一个启示,既然内核维护等待队列会阻塞线程,那可不可以由用户态来维护等待队列呢。当获取协程同步对象失败时,用户将条件不满足的协程加入一个由用户维护的协程等待队列,然后让出协程,等待条件满足时将协程重新加入协程调度器调度。看,我们解决了线程同步问题,而且没有阻塞线程!
介绍完了原理,我们来看看实现,框架实现了一下以下几种协程同步原语
- CoMutex 协程锁
- CoCondvar 协程条件变量
- CoSemaphore 协程信号量
- Channel 消息通道
依赖关系如下:
CoMutex CoCondVar CoMutex CoCondVar
| | | |
----------- -----------
| |
V V
CoSemaphore Channel
为了保持通用性,我在部分代码采用了伪代码,你可以很容易地移植到你的协程框架上,当然如果你想看一下具体实现可翻到文章的最后找一下框架链接。
SpinLock 自旋锁
在此之前不得不提一下自旋锁。不管你是用TAS实现还是直接封装posix spin lock他们都有一个共同特点,就是不阻塞线程。我们的同步原语可以说都是基于自旋锁来实现,这里简单封装了一下posix自旋锁。
/**
* @brief 自旋锁
*/
class SpinLock : Noncopyable {
public:
using Lock = ScopedLock<SpinLock>;
SpinLock(){
pthread_spin_init(&m_mutex,0);
}
~SpinLock(){
pthread_spin_destroy(&m_mutex);
}
void lock(){
pthread_spin_lock(&m_mutex);
}
bool tryLock() {
return !pthread_spin_trylock(&m_mutex);
}
void unlock(){
pthread_spin_unlock(&m_mutex);
}
private:
pthread_spinlock_t m_mutex;
};
CoMutex 协程锁
CoMutex
的定义如下
/**
* @brief 协程锁
*/
class CoMutex : Noncopyable {
public:
using Lock = ScopedLock<CoMutex>;
bool tryLock();
void lock();
void unlock();
private:
// 协程所持有的锁
SpinLock m_mutex;
// 保护等待队列的锁
SpinLock m_gaurd;
// 持有锁的协程id
uint64_t m_fiberId = 0;
// 协程等待队列
std::queue<std::shared_ptr<Fiber>> m_waitQueue;
};
成员m_waitQueue
就是用户态维护的等待队列,维护等待这个锁的协程。
成员函数lock
的主要代码如下
void lock() {
...
// 第一次尝试获取锁
while (!tryLock()) {
// 由于进入等待队列和出队的代价比较大,所以再次尝试获取锁,
// 成功获取锁就返回
if(tryLock()){
...
return;
}
// 获取所在的协程
auto self = GetTHisFiber();
// 将自己加入协程等待队列
m_waitQueue.push(self);
// 让出协程
Yield;
}
...
}
我们尝试获取锁,如果获取失败就把自己放入等待队列并让出协程。
成员函数unlock
的主要代码如下
void unlock() {
...
auto Fiber = m_waitQueue.front();
...
// 释放协程锁
m_mutex.unlock();
...
// 将等待的协程重新加入调度
Schedule(fiber);
...
}
我们取出等待这个锁的协程,释放锁后将协程重新加入调度器。
通过一个很简单方式,我们在用户空间实现了互斥量。
使用样例
CoMutex mutex;
void a() {
for (int i = 0; i < 100000; ++i) {
CoMutex::Lock lock(mutex);
++n;
}
}
void b() {
for (int i = 0; i < 100000; ++i) {
CoMutex::Lock lock(mutex);
++n;
}
}
CoCondVar 协程条件变量
CoCondVar
的定义如下
/**
* @brief 协程条件变量
*/
class CoCondVar : Noncopyable {
public:
using MutexType = SpinLock;
/**
* @brief 唤醒一个等待的协程
*/
void notify();
/**
* @brief 唤醒全部等待的协程
*/
void notifyAll();
...
/**
* @brief 等待唤醒
*/
void wait(CoMutex::Lock& lock);
private:
// 协程等待队列
std::queue<std::shared_ptr<Fiber>> m_waitQueue;
// 保护协程等待队列
MutexType m_mutex;
...
};
和CoMutex
一样,协程条件变量也维护了一个等待队列。
成员函数notify
的主要代码如下
void notify() {
...
Fiber::ptr fiber;
// 减小锁的粒度
{
// 获取一个等待的协程
MutexType::Lock lock(m_mutex);
fiber = m_waitQueue.front();
m_waitQueue.pop();
}
// 将等待的协程重新加入调度
Schedule(fiber);
}
与协程锁的解锁类似,获取一个在等待队列里的协程重新加入调度器。
成员函数notifyAll
则是将全部等待的协程加入调度器。
成员函数notify
的主要代码如下
void wait(CoMutex::Lock& lock) {
// 获取本协程对象
auto self = GetThisFiber();
{
MutexType::Lock lock1(m_mutex);
// 将自己加入等待队列
m_waitQueue.push(self);
...
}
// 先解锁
lock.unlock();
// 让出协程
Yield;
// 重新获取锁
lock.lock();
}
注意,只有先将协程锁解锁了才能加入到等待队列,否则别的协程无法获取锁,被唤醒后要重新获取锁。
至此我们已经实现了两个重要的同步原语。
使用样例
CoMutex mutex;
CoCondVar condVar;
void cond_a() {
CoMutex::Lock lock(mutex);
LOG_INFO() << "cond a wait";
condVar.wait(lock);
LOG_INFO() << "cond a notify";
}
void cond_b() {
CoMutex::Lock lock(mutex);
LOG_INFO() << "cond b wait";
condVar.wait(lock);
LOG_INFO() << "cond b notify";
}
void cond_c() {
sleep(2);
LOG_INFO() << "notify cone";
condVar.notify();
sleep(2);
LOG_INFO() << "notify cone";
condVar.notify();
}
CoSemaphore 协程信号量
CoSemaphore
的定义如下
/**
* @brief 协程信号量
*/
class CoSemaphore : Noncopyable {
public:
CoSemaphore(uint32_t num) {
m_num = num;
m_used = 0;
}
void wait();
void notify();
private:
// 信号量的数量
uint32_t m_num;
// 已经获取的信号量的数量
uint32_t m_used;
// 协程条件变量
CoCondVar m_condvar;
// 协程锁
CoMutex m_mutex;
};
协程信号量是基于协程锁和协程条件变量的。
成员函数wait
的主要代码如下
void wait() {
CoMutex::Lock lock(m_mutex);
// 如果已经获取的信号量大于等于信号量数量则等待
while (m_used >= m_num) {
m_condvar.wait(lock);
}
++m_used;
}
在条件变量的wait里让出协程等待。
成员函数notify
的主要代码如下
void notify() {
CoMutex::Lock lock(m_mutex);
if (m_used > 0) {
--m_used;
}
// 通知一个等待的协程
m_condvar.notify();
}
有了协程锁和协程条件变量,协程信号量实现起来十分简单。
使用样例
CoSemaphore sem(5);
void sem_a() {
for (int i = 0; i < 5; ++i) {
sem.wait();
}
sleep(2);
for (int i = 0; i < 5; ++i) {
sem.notify();
}
}
void sem_b() {
sleep(1);
for (int i = 0; i < 5; ++i) {
sem.wait();
}
for (int i = 0; i < 5; ++i) {
sem.notify();
}
}
Channel 消息通道
Channel
主要是用于协程之间的通信,属于更高级层次的抽象。
在类的实现上采用了 PIMPL 设计模式,将具体操作转发给实现类
Channel
对象可随意复制,通过智能指针指向同一个 ChannelImpl
Channel
的定义如下
template<typename T>
class Channel {
public:
Channel(size_t capacity) {
m_channel = std::make_shared<ChannelImpl<T>>(capacity);
}
Channel(const Channel& chan) {
m_channel = chan.m_channel;
}
void close() {
m_channel->close();
}
operator bool() const {
return *m_channel;
}
bool push(const T& t) {
return m_channel->push(t);
}
bool pop(T& t) {
return m_channel->pop(t);
}
Channel& operator>>(T& t) {
(*m_channel) >> t;
return *this;
}
Channel& operator<<(const T& t) {
(*m_channel) << t;
return *this;
}
size_t capacity() const {
return m_channel->capacity();
}
size_t size() {
return m_channel->size();
}
bool empty() {
return m_channel->empty();
}
bool unique() const {
return m_channel.unique();
}
private:
std::shared_ptr<ChannelImpl<T>> m_channel;
};
ChannelImpl
的定义如下
/**
* @brief Channel 的具体实现
*/
template<typename T>
class ChannelImpl : Noncopyable {
public:
ChannelImpl(size_t capacity)
: m_isClose(false)
, m_capacity(capacity){
}
/**
* @brief 发送数据到 Channel
* @param[in] t 发送的数据
* @return 返回调用结果
*/
bool push(const T& t);
/**
* @brief 从 Channel 读取数据
* @param[in] t 读取到 t
* @return 返回调用结果
*/
bool pop(T& t);
ChannelImpl& operator>>(T& t) {
pop(t);
return *this;
}
ChannelImpl& operator<<(const T& t) {
push(t);
return *this;
}
/**
* @brief 关闭 Channel
*/
void close();
operator bool() {
return !m_isClose;
}
size_t capacity() const {
return m_capacity;
}
size_t size() {
CoMutex::Lock lock(m_mutex);
return m_queue.size();
}
bool empty() {
return !size();
}
private:
bool m_isClose;
// Channel 缓冲区大小
size_t m_capacity;
// 协程锁和协程条件变量配合使用保护消息队列
CoMutex m_mutex;
// 入队条件变量
CoCondVar m_pushCv;
// 出队条件变量
CoCondVar m_popCv;
// 消息队列
std::queue<T> m_queue;
};
成员函数push
的主要代码如下
bool push(const T& t) {
CoMutex::Lock lock(m_mutex);
if (m_isClose) {
return false;
}
// 如果缓冲区已满,等待m_pushCv唤醒
while (m_queue.size() >= m_capacity) {
m_pushCv.wait(lock);
if (m_isClose) {
return false;
}
}
m_queue.push(t);
// 唤醒m_popCv
m_popCv.notify();
return true;
}
成员函数pop
的主要代码如下
bool pop(T& t) {
CoMutex::Lock lock(m_mutex);
if (m_isClose) {
return false;
}
// 如果缓冲区为空,等待m_pushCv唤醒
while (m_queue.empty()) {
m_popCv.wait(lock);
if (m_isClose) {
return false;
}
}
t = m_queue.front();
m_queue.pop();
// 唤醒 m_pushCv
m_pushCv.notify();
return true;
}
成员函数close
的主要代码如下
void close() {
CoMutex::Lock lock(m_mutex);
if (m_isClose) {
return;
}
m_isClose = true;
// 唤醒等待的协程
m_pushCv.notify();
m_popCv.notify();
std::queue<T> q;
std::swap(m_queue, q);
}
通过Channel
我们很容易实现一个生产者消费者的样例
void chan_a(Channel<int> chan) {
for (int i = 0; i < 10; ++i) {
chan << i;
ACID_LOG_INFO(g_logger) << "provider " << i;
}
ACID_LOG_INFO(g_logger) << "close";
chan.close();
}
void chan_b(Channel<int> chan) {
int i;
while (chan >> i) {
ACID_LOG_INFO(g_logger) << "consumer " << i;
}
ACID_LOG_INFO(g_logger) << "close";
}
void test_channel() {
IOManager loop{};
Channel<int> chan(5);
loop.submit(std::bind(chan_a, chan));
loop.submit(std::bind(chan_b, chan));
}
最后
整套协程同步原语的核心其实就是协程队列,我们通过在用户态模拟了等待队列达到了原生同步原语的效果。并对之进行更高层次的抽象,得到了Channel,它使代码变得简洁优雅,不用考虑协程间的同步问题。
项目地址:zavier-wong/acid: A high performance fiber RPC network framework. 高性能协程RPC网络框架 (github.com)