目录
chapter2 线程同步精要
1 互斥器
主要原则:
- 用RAII手法封装mutex的创建、销毁、加锁、解锁
- 只使用不可重入锁
- 不手工调用lock()和unlock()函数,交给栈上的Guard对象的构造和析构函数负责。Guard对象的生命期正好等于临界区,保证始终在同一个函数同一个scope里对某个mutex加锁和解锁(Scoped Locking)
- 构造Guard对象时,避免死锁
次要原则:
- 不使用跨进程的mutex,进程间通信只使用TCP sockets
- 加锁、解锁在同一个线程,线程a不能unlock线程b已经锁住的mutex(RAII自动保证)
- 别忘了解锁(RAII自动保证)
- 不重复解锁(RAII自动保证)
- 必要的时候使用PTHREAD_MUTEX_ERRORCHECK来排错
1.1 只使用不可重入锁(非递归锁)
可重入锁与不可重入锁的区别可参考博客
非递归的优越性:重复加锁会导致死锁,把程序的逻辑错误暴露出来
1.2 死锁
- 有时,一个线程需要同时访问两个或更多不同的共享资源,而每个资源又都由不同的互斥量管理。当超过一个线程加锁同一组互斥量时,就有可能发生死锁。
- 两个或两个以上的进程在执行过程中,因争夺共享资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁。
- 死锁的几种场景:
- 忘记释放锁
- 重复加锁
- 多线程多锁,抢占锁资源
2 条件变量
互斥器是加锁原语,用来排他性地访问共享数据,它不是等待原语。
如果需要等待某个条件成立,应该使用条件变量。
条件变量的使用方式:
wait端:
- 与mutex一起使用,该布尔表达式的读写需受此mutex保护
- 在mutex上锁的时候调用wait()
- 把判断布尔条件和wait()放到while循环中
signal/broadcast端:
- 不一定要在mutex已上锁的情况下调用signal(理论上)
- 在signal之前一般要修改布尔表达式
- 修改布尔表达式要用mutex保护
- 注意区分signal和broadcast,broadcast表示状态变化、signal表示资源可用
3 不要使用读写锁和信号量
4 封装MutexLock、MutexLockGuard、Condition
class MutexLock : boost::nocopyable {
public:
MutexLock() : holder_(0) {
pthread_mutex_init(&mutex_, NULL);
}
~MutexLock() {
assert(holder_ == 0);
pthread_mutex_destory(&mutex_);
}
bool isLockedByThisThread() {
return holder_ == CurrentThread::tid();
}
void assertLocked() {
assert(isLockedByThisThread());
}
void lock() { //仅供MutexLockGuard调用
pthread_mutex_lock(&mutex_);
holder_ = CurrentThread::tid();
}
void unlock() { //仅供MutexLockGuard调用
holder_ = 0;
pthread_mutex_unlock(&mutex_);
}
pthread_mutex_t* getPthreadMutex() { //仅供Condition调用
return &mutex_;
}
private:
pthread_mutex_t mutex_;
pid_t holder_;
};
class MutexLockGuard : boost::nocopyable {
public:
explicit MutexLockGuard(MutexLock& mutex) : mutex_(mutex) {
mutex_.lock();
}
~MutexLockGuard() {
mutex_.unlock();
}
private:
MutexLock& mutex_;
};
#define MutexLockGuard(x) static_assert(false, "missing mutex guard var name")
class Condition : boost::nocopyable {
public:
explicit Condition(MutexLock& mutex) : mutex_(mutex) {
pthread_cond_init(&pcond_, NULL);
}
~Condition() {
pthread_cond_destory(&pcond_);
}
void wait() {
pthread_cond_wait(&pcond_, mutex.getPthreadMutex());
}
void notify() {
pthread_cond_signal(&pcond_);
}
void notifyAll() {
pthread_cond_broadcast(&pcond_);
}
private:
MutexLock& mutex_;
pthread_cond_t pcond_;
}
5 小结
- 线程同步的四项原则,尽量使用高层同步设施(线程池、队列、倒计时)
- 使用普通互斥器和条件变量完成剩余的同步任务,采用RAII惯用手法和Scoped Locking
6 借shared_ptr实现copy-on-write
用shared_ptr来管理共享数据:
- shared_ptr是引用计数型智能指针,如果当前只有一个观察者,那么引用计数的值为148。
- 对于write端,如果发现引用计数为1,这时可以安全地修改共享对象,不必担心有人正在读它。
- 对于read端,在读之前把引用计数加1,读完之后减1,这样保证在读的期间其引用计数大于1,可以阻止并发写。
- 对于write端,如果发现引用计数大于1,该如何处理?(拷贝一份,在副本上修改)
chapter3 多线程服务器的适用场合与常用编程模型
1 进程与线程
进程是程序运行的实例,每个进程都有自己独立的地址空间
可以类比为“人”,每个人都有自己的记忆,但不知道别人的记忆,通过谈话(消息传递)来交流。
线程的特点是共享地址空间,从而可以高效地共享数据
2 单线程服务器的常用编程模型
Reactor模式:non-blocking IO + IO multiplexing
程序的基本结构:一个事件循环(event loop),以事件驱动(event-driven)和事件回调的方式实现业务逻辑
优点:编程简单,效率不错。不仅可用于读写socket,连接的建立甚至DNS解析都可以用非阻塞方式进行,以提高并发度和吞吐量,对于IO密集型的应用是不错的选择
缺点:事件回调函数必须是非阻塞的,对于涉及网络IO的请求响应式协议,容易割裂业务逻辑,使其散布于多个回调函数中,相对不容易理解和维护
3 one loop per thread
程序里的每个IO线程有一个event loop,用于处理读写和定时事件。
优点:
- 线程数目固定,可以在程序启动时设置,不会频繁创建和销毁
- 可以方便地在线程间调配负载
- IO事件发生的线程是固定的,同一个TCP连接不必考虑事件并发
Eventloop代表线程的主循环,需要让哪个线程干活,就把timer或IO channel注册到哪个线程的loop里即可。
推荐模型:
one (event)loop per thread + thread pool
- event loop(IO loop)用作IO multiplexing,配合NIO和定时器
- thread pool用作计算,具体可是任务队列或生产者消费者队列
4 进程间通信只用TCP
4.1 TCP sockets与pipe
共同点:本质都是操作文件描述符来收发字节流
不同点:
sockets:可以跨主机,具有伸缩性,双向通信
pipe:进程间需要亲缘关系才能使用,进程间双向通信需要两个文件描述符
4.2 分布式系统中使用TCP长连接通信的好处
- 容易定位分布式系统中的服务之间的依赖关系。
- 通过接受和发送队列的长度来定位网络和程序故障
5 多线程服务器的适用场合
服务端程序的基本任务:处理并发连接
主要模型:
- 运行一个单线程的进程:不可伸缩,无法发挥多核机器的计算能力
- 运行一个多线程的进程:程序复杂
- 运行多个单线程的进程:
- 简单把模式1中的进程运行多份
- 主进程+worker进程
- 运行多个多线程的进程
5.1 必须用单线程的场合
- 程序可能会fork:只有单线程程序能fork
- 限制程序的CPU占用率:单线程程序能限制程序的CPU占用率
优点:简单
缺点:事件优先级不同,可能出现优先级反转,导致延迟
5.2 多线程的适用场景
提高响应速度,让IO和“计算”相互重叠,降低延迟。虽然多线程不能提高绝对性能,但能提高平均响应性能。
5.3 线程分类
- IO线程,这类线程的主循环是IO multiplexing,阻塞地等待在select/poll/epoll_wait系统调用上。这类线程也处理定时事件。
- 计算线程。主循环是blocking queue,阻塞地等待在condition variable上。这类线程一般位于线程池中,通常不涉及IO,因此要避免任何阻塞操作
- 第三方库使用的线程,比如logging,又比如database connection