Mutex
使用mutex时,注意几个原则,可以减少代码错误:
- 使用RAII原则对mutex进行封装,封装其创建、销毁、加锁、解锁的操作。
- 只使用不可重入的mutex
- 不手动调用
lock
和unlock
,一切让在栈上的Guard对象的构造和析构函数完成.Guard对象的生命周期恰好等于临界区. - 注意加锁顺序
- 不使用跨进程的mutex,进程间通信只用TCP sockets
- 加锁,解锁操作在同一个线程完成,避免跨线程
- 不要重复加锁与解锁(RAII自动保证)
non-recursive mutex
non-recursive mutex也称为不可重入锁.在同一个进程对其重复加锁会造成死锁,(想象一下,加第一锁成功了,加第二次锁,需要等待第一次锁释放,但是其永远不会释放了)
void *f1(void *p)
{
pthread_mutex_lock(&lock);
pthread_mutex_lock(&lock);
printf("Hello 1\n");
pthread_mutex_unlock(&lock);
pthread_exit(nullptr);
}
void *f2(void *p)
{
pthread_mutex_lock(&lock);
printf("Hello 2\n");
pthread_mutex_unlock(&lock);
pthread_exit(nullptr);
}
int main()
{
pthread_mutex_init(&lock, NULL);
pthread_t p1, p2;
pthread_create(&p1, NULL, f1, NULL);
pthread_create(&p2, NULL, f2, NULL);
getchar();
printf("END\n");
}
就会永远卡在f1
中. 不可重入锁会将潜在的问题直接暴露出来(直接死锁),方便调试.
解决二次加锁问题:提供xxxWithLockHold 版本
如:
struct Foo{
void f1(){
lock();
print();
}
void print(){
lock();
// doSomething
}
};
调用f1
就会死锁,我们可以提供print
的其他版本:
struct Foo{
void f1(){
lock();
printWithLockHold();
}
void print(){
lock();
printWithLockHold();
}
void printWithLockHold(){
// doSomething
}
};
条件变量
注意使用方式,就几乎不会出错.
wait端
- 必须与mutex一起使用,该布尔表达式的读写受此互斥锁的保护
- 在mutex上锁的时候才能调用
wait()
- 把判断布尔条件和
wait()
放到while
循环中
示例代码
MutexLock mutex;
Condition cond(mutex);
queue<int> queue;
int dequeue(){
MutexLockGuard(mutex);
while(queue.empty()){ // 这个是判断条件,写为while
cond.wait(); // 这一步会原子地unlock mutex并进入等待,wait执行完毕会重新加锁
}
assert(!queue.empty());
int top = queue.front();
queue.pop_front();
return top;
}
必须用while
来等待条件变量,而不是if语句,是为了防止假唤醒(spurious wakeup) 即:判断条件写成while
signal/broadcast端
- 理论上不一定要在mutex上锁的情况下调用signal
- 在signal之前一般修改布尔表达式
- 修改布尔表达式一般需要mutex来保护
- 注意区分broadcast和signal, broadcast一般表示状态变化,signal一般表示资源可用
示例代码
void enqueue(int x){
MutexLockGuard lock(mutex);
queue.push_back(x);
cond.notify();
}
配合上面就实现了一个简单的BlockingQueue.
示例,计数器
class Counter{
private:
int cnt;
mutable MutexLock mutex;
Condition cond;
public:
void wait(){
MutexLockGuard lock(mutex);
while(cnt > 0){
condition.wait();
}
}
void countDown(){
MutexLockGuard lock(mutex);
--cnt;
if(cnt == 0){
// 表示状态变化
condition.notifyAll();
}
}
}
非必要不使用读写锁和信号量
- 从正确性方面来说,一种典型的易犯错误是在持有
read lock
的时候修改了共享数据。这通常发生在程序的维护阶段。程序员不小心在原来read lock
保护的函数中调用了会修改状态的函数 - 从性能方面来说,读写锁不见得比普通的 mutex_ 更高效。因为它要更新 reader 的数目。如果临界区很小,锁竞争不激烈,那么 mutex 往往会更快。
Example,互斥锁+shared_ptr实现读写锁的功能
读
读取的时候获取对应的shared_ptr
副本(获取的时候需要加锁,获得副本之后即释放锁),然后就可以读取数据
写
全程需要加锁 写前先检查智能指针的计数是否为1
- 为1的话说明没有其他线程在读,可以直接修改数据
- 不为1说明有其他线程在读,创建一个对应智能指针的副本,与原始智能指针交换,然后在交换后的数据上修改
存在的问题
读取的数据可能不是最新的:其他线程获得了数据getData()
,然后读取中,这个时候某线程修改了数据,但是之前的线程读取的还是先前的数据.
#include "../base/Lock.h"
#include "stdio.h"
#include "map"
#include "vector"
#include "memory"
class CustomerData
{
private:
using Entry = std::pair<std::string, int>; // stock-时间
using EntryList = std::vector<Entry>; // 不同stock的最小交易间隔,已经排好序
using Map = std::map<std::string, EntryList>; // 用户名-EntryList
using MapPtr = std::shared_ptr<Map>;
void update(const std::string &customer, const EntryList &ent);
// 在EntryList中寻找股票
static int findEntry(const EntryList &en, const std::string &stock);
// 获取数据,对应读锁
MapPtr getData()
{
mymuduo::MutexLockGuard lock(mutex);
// 这里返回一个shared_ptr副本,在读的话,那么当前shared_ptr的use_count大于1
return data;
}
mutable mymuduo::MutexLock mutex;
MapPtr data;
public:
CustomerData() : data(new Map), mutex() {}
int query(const std::string &customer, const std::string &stock);
};
int CustomerData::query(const std::string &customer, const std::string &stock)
{
// 获取其副本,计数+1,在获取副本的时候需要锁
MapPtr temp = getData();
// 不需要锁了
Map::const_iterator it = temp->find(customer);
return (it != temp->end()) ? findEntry(it->second, stock) : -1;
}
void CustomerData::update(const std::string &customer, const EntryList &ent)
{
mymuduo::MutexLockGuard lock(mutex);
if (!data.unique())
{
MapPtr newData = std::make_shared<Map>(*data);
data.swap(newData);
}
assert(data.unique());
(*data)[customer] = ent;
}
推荐的多线程编程模型
one (event) loop per thread
一般配合非阻塞IO和IO多路复用使用。
在此模型下,每个IO线程都有一个事件循环(event loop),来处理读写任务和定时任务。代码框架如下:
while(run){
int number = epoll_wait(epollfd, events, MAX_EVENTNUM, -1);
if(number < 0 && errno != EAGAIN){
printf("EPOLL FAILURE\n");
break;
}
for(int i = 0; i < number; i++){
int sockfd = events[i].data.fd;
if(sockfd == listenfd){
// ...
}
// .....
}
}
优势在于:
- 线程数目固定,不需要频繁创建与销毁
- IO事件发生的线程是固定的,同一个TCP连接不用考虑事件并发
- 方便调度线程
线程池
线程池可以避免线程的频繁创建与销毁,减少了线程切换的问题。如果使用线程池来处理IO事件的话,需要配合EPOLLONESHOT使用,使得epoll事件只响应一次。
BlockingQueue<T>
可以实现数据的消费者生产者队列,可以将事件放到阻塞队列上,线程池来争用,从而实现服务,缺点是不能保证每个socket都是由同一个线程服务的。
推荐模式
- event loop用作IO多路复用,配合非阻塞IO和定时器
- thread pool用作计算任务
进程间通信
一般进程间通信的可选技术是:pipe,消息队列,socket,共享内存,信号量,信号。 在陈硕认为仅使用TCP SOCKET是足够的,这里罗列其优势:
- 减少项目复杂度
- 在使用分布式项目时,共享内存需要通过网络同步,这时候不如使用socket
- TCP socket的调试工具较多,可以检测进程间通信(wireshark)
- 跨语言方便,与Java、Python等其他服务写的程序交互简单
- 基于字节流,可选Protocol Buffers作为序列化和反序列化技术