设计基于锁的并发数据结构的关键点在于,要确保先锁定合适的互斥,再访问数据,并尽可能缩短持锁时间。
一、线程安全的queue
如下:内存操作往往是成本相当高的操作,而新的队列以安全方式为其免除了锁保护,遂缩短了互斥的持锁时长,在分配内存的时候,还运行其他线程在队列容器上执行操作,因此非常有利于增强性能。
cpp
#include <condition_variable>
#include <mutex>
#include <queue>
template <typename T>
class threadsafe_queue {
private:
mutable std::mutex mut;
std::queue<std::shared_ptr<T>> data_queue;
std::condition_variable data_cond;
public:
threadsafe_queue() {}
void wait_and_pop(std::shared_ptr<T>& value) {
std::unique_lock<std::mutex> lock(mut);
data_cond.wait(lock, [this] { return !data_queue.empty(); });
value = std::move(*data_queue.front());
data_queue.pop();
}
bool try_pop(T& value) {
std::lock_guard<std::mutex> lk(mut);
if (data_queue.empty()) return false;
value = std::move(*data_queue.front());
data_queue.pop();
return true;
}
std::shared_ptr<T> wait_and_pop() {
std::unique_lock<std::mutex> lock(mut);
// ↓ 关键:条件变量的wait方法实现阻塞
data_cond.wait(lock, [this] { return !data_queue.empty(); });
std::shared_ptr<T> res = data_queue.front();
data_queue.pop();
return res;
}
std::shared_ptr<T> try_pop() {
std::lock_guard<std::mutex> lk(mut);
if (data_queue.empty()) return std::shared_ptr<T>();
std::shared_ptr<T> res = data_queue.front();
data_queue.pop();
return res;
}
void push(T new_value) {
std::shared_ptr<T> data(std::make_shared<T>(std::move(new_value)));
std::lock_guard<std::mutex> lock(mut);
data_queue.push(data);
data_cond.notify_one();
}
bool empty() const {
std::lock_guard<std::mutex> lk(mut);
return data_queue.empty();
}
};
其中wait_and_pop()函数在没有pop数据时,会阻塞线程,线程进入睡眠状态,而try_pop()函数只是判断,如果没有数据就直接返回了。用条件变量实现的睡眠是真正睡眠,不消耗CPU资源。如果实现方式如下:
cpp
// 错误的忙等待方式(浪费CPU)
while (data_queue.empty()) {
// 空循环,浪费CPU时间片
}
会浪费CPU的资源
二、基于精细粒度的锁和条件变量实现的线程安全的queue
如下,实现简单的加锁的单项链表:
cpp
template <typename T>
class queue {
private:
struct node {
T data;
std::unique_ptr<node> next;
node(T data_) : data(std::move(data_)) {}
};
std::unique_ptr<node> head;
node* tail;
public:
queue() : tail(nullptr) {}
queue(const queue& other) = delete;
queue& operator=(const queue& other) = delete;
std::shared_ptr<T> try_pop() {
if (!head) {
return std::shared_ptr<T>();
}
std::shared_ptr<T> res(std::make_shared<T>(std::move(head->data)));
std::unique_ptr<node> const old_head = std::move(head);
head = std::move(old_head->next);
if (!head) tail = nullptr;
return res;
}
void push(T new_value) {
std::unique_ptr<node> p(new node(std::move(new_value)));
node* const new_tail = p.get();
if (tail) {
tail->next = std::move(p);
} else {
head = std::move(p);
}
tail = new_tail;
}
};
以上最明显的问题是,Push()可以同时改动head指针和tail指针,所以该函数就需要将两个互斥都锁住。尽管这并不合适,但同时锁住这两个互斥的做法还算可行,问题不严重。
严重的问题在于,push()和try_pop()有可能并发访问同一节点的next指针:push()更新tail->next,而try_pop()则读取head->next。如果队列仅含有一项数据,即head = tail,那么head->next和tail->next两个指针的目标节点重合,而它需要保护。
如下针对以上问题新增一个head锁和tail锁,同时新增判断queue是否为空的操作,具体实现如下:
cpp
template <typename T>
class threadsafe_queue {
private:
struct node {
std::shared_ptr<T> data;
std::unique_ptr<node> next;
};
std::mutex head_mutex;
std::unique_ptr<node> head;
std::mutex tail_mutex;
node* tail;
node* get_tail() {
std::lock_guard<std::mutex> tail_lock(tail_mutex);
return tail;
}
std::unique_ptr<node> pop_head() {
std::lock_guard<std::mutex> head_lock(head_mutex);
if (head.get() == get_tail()) {
return nullptr;
}
std::unique_ptr<node> old_head = std::move(head);
head = std::move(old_head->next);
return old_head;
}
public:
threadsafe_queue() : head(new node), tail(head.get()) {}
threadsafe_queue(const threadsafe_queue& other) = delete;
threadsafe_queue& operator=(const threadsafe_queue& other) = delete;
std::shared_ptr<T> try_pop() {
std::unique_ptr<node> old_head = pop_head();
return old_head ? old_head->data : std::shared_ptr<T>();
}
void push(T new_value) {
std::shared_ptr<T> new_data(std::make_shared<T>(std::move(new_value)));
std::unique_ptr<node> p(new node);
node* const new_tail = p.get();
std::lock_guard<std::mutex> tail_lock(tail_mutex);
tail->data = new_data;
tail->next = std::move(p);
tail = new_tail;
}
};
唯一需要注意的是:这种设计在队列为空时,pop操作需要同时获取head和tail锁,但这是必要的安全检查,不是风险!!!
三、基于细颗粒度锁实现的list
基本思想是让每个节点都具备自己的互斥,如果链表增长,互斥数量也会变多!这种做法的好处是,可以在链表不同部分执行真正的并发操作:每个操作仅仅需要锁住目标节点,当操作转移到下一个目标节点时,原来的锁即可解开。
cpp
template <typename T>
class threadsafe_list {
struct node {
std::mutex m;
std::shared_ptr<T> data;
std::unique_ptr<node> next;
node() : next() {}
node(T const& value) : data(std::make_shared<T>(value)) {}
};
node head;
public:
threadsafe_list() {}
threadsafe_list() {
remove_if([](node const& n) { return true; });
}
threadsafe_list(threadsafl_list const& other) = delete;
threadsafe_list& operator=(threadsafe_list const& other) = delete;
void push_front(T const& value) {
std::unique_ptr<node> new_node(new node(value));
// 只锁头节点
std::lock_guard<std::mutex> lk(head.m);
new_node->next = std::move(head.next);
head.next = std::move(new_node);
}
template <typename Function>
void for_each(Function f) {
node* current = &head;
std::unique_lock<std::mutex> lk(head.m);
while (node* const next = current->next.get()) {
std::unique_lock<std::mutex> next_lk(next->m);
// 释放当前节点锁
lk.unlock();
// 执行函数
f(*next->data);
current = next;
// 移动所有权
lk = std::move(next_lk);
}
}
template <typename Predicate>
std::shared_ptr<T> find_first_if(Predicate p) {
node* current = &head;
std::unique_lock<std::mutex> lk(head.m);
while (node* const next = current->next.get()) {
std::unique_lock<std::mutex> next_lk(next->m);
lk.unlock();
if (p(*next->data)) {
return next->data;
}
current = next;
lk = std::move(next_lk);
}
return std::shared_ptr<T>();
}
template <typename Predicate>
void remove_if(Predicate p) {
node* current = &head;
std::unique_lock<std::mutex> lk(head.m);
while (node* const next = current->next.get()) {
std::unique_lock<std::mutex> next_lk(next->m);
if (p(*next->data)) {
std::unqiue_ptr<node> old_next = std::move(current->next);
current->next = std::move(next->next);
} else {
lk.unlock();
current = next;
lk = std::move(next_lk);
}
}
}
};
手递手锁(Hand-over-hand Locking)
这是最核心的设计模式,确保在遍历链表时:
同时只锁定当前节点和下一个节点
移动时先锁定下一个节点,再释放当前节点锁
避免死锁和竞态条件
流程:
锁定当前节点
锁定下一个节点
释放当前节点锁
处理数据
移动锁所有权