《Liunx多线程服务器编程》笔记 第一章

1. 线程安全的对象生命期管理

1.1 线程安全

编写线程安全的类不是难事,使用同步原语(synchronization primitives)保护内部状态即可。但是对象的生死不能由对象自身拥有的mutex(互斥锁)来保护。

1.1.1 线程安全的定义

依据[JCP],应该线程安全的 class 应该满足:

  • 多个线程同时访问时,其表现出正确的行为。
  • 无论操作系统如何调度这些线程,无论这些线程的执行顺序如何交织。
  • 调用端代码无需额外的同步或其他协调动作。

C++ 标准库中大多数的 class 都是线程不安全的。

1.2 对象的创建

对象构造要做到线程安全,唯一的要求就是在构造期间不要泄露 this 指针,即

  • 不要在构造函数中注册任何回调。
  • 不要在构造函数中把 this 传给跨线程对象。
  • 即使是构造完成后也不要这么,因为该 class 可能是某个基类,而派生类的构造会先调用基类,也就是说你以为你构造完毕了,但实际上只是派生类构造的一个过程。

特例:如果该 class 具有 final 关键词,那可以在构成完成后注册回调或者传递 this。

若该 class 必须进行回调,比如说网络连接的 class ,那二段式构造——即构造函数+initialize()——有时会是好办法,但不符合 c++ 的教条。

1.3 对象的销毁

1.3.1 作为数据成员的 mutex 不能保护析构

另外,如果要同时读写一个 class 的两个对象,有潜在的死锁可能,如:

void swap(Counter &a,Counter &b){
    lock_guard alock(a.mutex);
    lock_guard alock(b.mutex);
    /* ... */
}

若两个线程,线程 A 执行 swap(a,b) ,线程 B 执行 swap(b,a) ,就可能导致死锁,A 线程先将 a 锁上,B 线程先将 b 锁上,这时 A 线程将等待 b 的解锁,B 线程将等待 a 的解锁。
一个函数如果要锁住相同类型的多个对象,为保证加锁的顺序始终一样,我们可以先比较mutex的地址,始终先加锁地址小的。

1.4 解决方案

多线程使用 class 有两个很大的问题。

  1. 如何知道该对象已经被销毁?
  2. 该对象将何时销毁?

如,有两个指针 p1,p2 同时指向一个对象。

Object *p1,*p2;
p1=p2=new Object();

这种情况如果执行

delete p1;
p1=nullptr;

通过 p1 将对象销毁,p2 将没有任何途径知道它所指向的对象已经被销毁,这是 p2 就成为了一个空悬指针
以下两种方案正是为了解决这种问题。

1.4.1 引入间接层(二级指针)

Object **p1,**p2;
Object *proxy= new Object();
p1=p2=proxy;

同样,我们使用 p1 执行销毁操作。

delete *p1;
*p1=nullptr;

这时如果我们使用 p2 去获取对象,就会发现 p2 指向的 proxy 指针是空值,就知道对象已经被销毁了。
但这个方法有个缺陷,问题在于,何时释放 proxy 指针呢。

1.4.2 引用计数

为了安全的释放 proxy 我们可以引入引用计数。

class proxy {
	Object *ptr;
	int *count;
public:
	proxy() : ptr(nullptr), count(nullptr) {}
	proxy(Object *ptr):ptr(ptr) {
		count = new int(0);
	}
	proxy(const proxy& x) {
		ptr = x.ptr;
		count = x.count;
		++(*count);
	}
	proxy& operator=(const proxy& x) {
		if(count&&*count==1){
			delete ptr;
			delete count;
		}
		ptr = x.ptr;
		count = x.count;
		++(*count);
		return *this;
	}
	~proxy() {
		--count;
		if (count == 0) {
			delete ptr;
			delete count;

		}
	}
};

{
    proxy p1;
    {
        proxy p2=proxy(new Object());   //创建对象,计数器赋1
        p1=p2;                         //p2也指向该对象,计数器为2
    }
    //p2销毁,计数器为1
    //至此对象并没有被销毁
}
//p1销毁,计数器为0
//对象被销毁

1.5 shared_ptr / weak_ptr

C++11 标准库中有提供引用计数型智能指针,即 shared_ptr / weak_ptr

  • shared_ptr 控制对象的生命周期。shared_ptr 是强引用,只要有一个指向对象 x 的 shared_ptr 存在对象 x 就不会析构,当没有一个 shared_ptr 指向对象 x 时,对象 x 保证被销毁。
  • weak_ptr 不控制对象的生命周期,但它可以知道对象是否还活着。如果对象活着它可以提升成一个有效的 shared_ptr 如果对象已经死了,提升会失败。
  • shared_ptr / weak_ptr 的“计数”在主流平台上是原子操作,没用锁,性能不俗。
  • shared_ptr / weak_ptr 的线程安全级别与 std::string 和 STL 容器一样。

1.6 插曲:C++ 内存问题

C++ 中可能出现的内存问题大致有这么几个方面:

  1. 缓冲区溢出
  2. 空悬指针/野指针
  3. 重复释放
  4. 内存泄漏
  5. 不配对的 new[] / delete
  6. 内存碎片

正确的使用智能指针可以解决以上5个问题。

  1. 缓冲区溢出:使用std::vector / std::string 或者自己编写 Buffer class 来管理缓冲区,自动记住用缓冲区的长度,并通过成员函数而不是裸指针来修改缓冲区。
  2. 空悬指针/野指针:使用 shared_ptr / weak_ptr 。
  3. 重复释放:用 shared_ptr ,对象只会析构一次。
  4. 内存泄漏:用 shared_ptr ,对象析构会自动释放内存。
  5. 不配对的 new[] / delete:将 new[] 统统替换为 std::vector / scoped_array。

现代的 C++ 程序中一般不会出现 delete ,资源都是通过对象进行管理的,不需要程序员操心。
需要注意的一点是,shared_ptr / weak_ptr 都是值语义,几乎不会有下面这种用法:

shared_ptr<Foo>* pFoo = new shared_ptr<Foo>(new Foo); // WEONG semantic

1.8 shared_ptr的技术与陷阱

1.8.1 意外延长对象的生命周期

因为 shared_ptr 是强引用,如果不小心遗留了一份拷贝,那么对象的生命周期可能会预料之外的延长,这也是 Java 内存泄漏的常见原因。
另外要注意的是使用 boost::bing,boost::bing 会把实参拷贝一份,如果参数是 shared_ptr ,那么对象的生命周期就不会短于 boost::function 对象。

1.8.2 函数参数

shared_ptr 的拷贝开销比原始指针要高,所以多数情况下推荐使用 const reference 方式传递。

1.8.3 析构动作会在创建时被捕获

这意味着:

  • 虚析构不再是必须,使用 shared_ptr 的对象在创建时就绑定了析构函数,当函数销毁时直接就调用该析构,而不会管目前的智能指针是什么类型。
  • shared_ptr 能持有任何对象,并且可以安全释放。
  • shared_ptr 对象可以安全地跨域块边界。
  • 二进制兼容性
  • 析构动作可以定制

1.8.4 析构所在线程

当最后一个指向该对象的智能指针离开其作用域(即销毁)后,对象将在这个线程进行销毁。如果对象的析构比较耗时,可能会拖慢关键线程的速度,所以我们可以通过一定的方式避免,对象在关键线程如临界区进行析构,比如可以用一个单独的线程来专门析构。可以通过BlockingQueue<shared_ptr>,来把对象的析构都移动到一个专用的线程。

1.8.5 避免循环引用

循环引用会导致对象不会被销毁,通常的做法是,owner 拥有指向 child 的 shared_ptr ,child 持有指向 owner 的weak_ptr。

1.9 定制析构

shared_ptr 的构造函数( reset 方法)额外接收一个参数,可以传入一个函数指针或者仿函数 d(ptr),ptr为 shared_ptr 保存的对象指针。

void f(int * x);
shared_ptr<int> x(new int, f);

class Stock{/*...*/};
class StockFactory{
	void deleteStock(Stock *);
	/*...*/
};

shared_ptr<Stock> ptr;
ptr.reset(new Stock(key),bind(&StockFactory::deleteStock,this,_1));

1.10 enable_shared_from_this

继承 enable_shared_from_this class,可以使该 class 使用 shared_ptr 管理 this 指针。

class Foo : public boost:enable_shared_from_this<Foo>{/*..*/}

另外要注意的是,为了使用 shared_from_this(), 对象不能是 stack object ,必须是 heap object 且由 shared_ptr 管理生命周期。

ptr.reset(new Stock(key),bind(&StockFactory::deleteStock,shared_from_this(),_1));

1.11 弱回调

使用 enable_shared_from_this 方法传递对象的 shared_ptr 有一个缺陷,虽然这个方法是安全的,但这同时延长了对象的生命周期。有时我们需要“如果对象还活着就调用它的成员函数,否则忽略”这样的语境,我称之为“弱回调”。这是就可以使用,weak_ptr 这样对象的生命周期就不会延长,如果 weak_ptr 能提升成 shared_ptr 那就调用,如果不能就忽略。

class Stock{/*...*/};
class StockFactory{
	static void weakDeleteCallback(const boost:weak_ptr<StockFactory>& ,Stock*);
	/*...*/
};
shared_ptr<Stock> pStock;
pStock.reset(new Stock(key),boost::bind(&StockFactory::weakDeleteCallback,boost::weak_ptr<StockFactory>(shared_from_this()) ,_1));

1.12 心得与小结

1.12.1 心得

虽然本章写的是如何安全的使用跨线程对象,但实际上尽量减少使用跨线程对象,不使用跨线程对象,自然不会遇到本章描述的各种险态。
“用流水线,生产者消费者,任务队列这些有规律的机制,最低限度地共享数据。这是我所知的最好的多线程编程的建议了”

1.12.2 小结

  • 原始指针暴露给多个线程会导致各种问题。
  • 统一用 shared_ptr / weak_ptr 管理对象的生命周期,在多线程中尤为重要。
  • shared_ptr 是值语义,当心意外延长对象的生命周期。
  • weak_ptr 是 shared_ptr 的好搭档,可以用作弱回调、对象池等。
  • 认真阅读 boost::shared_ptr 的文档,能学到很多东西。

2 线程同步精要

并发编程有两种基本模型

  • message passing
  • shared memory
    在分布式系统中,只有 message passing 这一种实用模型,message passing 也更容易保证程序的正确性。

线程同步的四项原则,按重要性排列:

  1. 首要原则是降低共享对象,一个对象能不暴露给其他线程就不暴露,实在要暴露,优先考虑 immutable 对象,实在不行才暴露可修改的对象,并且使用同步措施充分进行保护。
  2. 其次是使用高级的并发编程构件,如 TaskQueue 、 Producer-Consumer Queue 、CountDownLatch 等等。
  3. 最后不得已必须使用底层同步原语时,只用非递归的互斥器和条件变量,慎用读写锁,不要用信号量。
  4. 除了使用 atomic 整数之外,不自己编写 lock-free 代码,也不要用“内核级”同步原语。

2.1 互斥器(mutex)

互斥器恐怕是使用得最多的同步原语。粗略的说,任何时间只能有一个线程在互斥器划分出的临界区中活动。
使用互斥器的原则:

  • 使用 RAII 手法封装的 mutex 的创建、销毁、加锁、解锁这四个操作。
  • 只用非递归的 mutex (即不可重入的 mutex)。
  • 不手工调用 lock() 和 unlock() 函数,保证一切交给栈上的 Guard 对象的构造和析构函数负责
  • 在每一次构造 Guard 对象的时候,思考一路上已经持有的锁,防止因加锁顺序导致的死锁。

次要原则有:

  • 不使用跨进程的 mutex,进程间通信只使用 TCP sockets。
  • 加锁、解锁在同一个线程(RAII自动保证)。
  • 记得解锁(RAII自动保证)。
  • 不重复解锁(RAII自动保证)。
  • 必要的时候可以考虑使用 PTHREAD_MUTEX_ERRORCHECK 来排错。

2.1.1 只使用非递归的 mutex

mutex 分为:

  • 递归(recursive)
  • 非递归(non-recursive)

或者称为:

  • 可重入(reentrant)
  • 非可重入

它们唯一的区别就是:同一个线程可以重复对 recursive mutex 加锁,但是不能重复对 non-recursive mutex 加锁。
多次对 non-recursive mutex 加锁会立刻导致死锁,而 recursive mutex 不会,毫无疑问 recursive mutex 使用起来更为方便,但正因为它的方便,recursive mutex 可能会隐藏代码中的一些问题。典型情况就是你以为拿到一个锁就可以修改对象了,没想到外层代码已经拿到了锁,正在修改同一个对象呢。
使用 non-recursive mutex 的优越性在于,如果出现了这种情况,non-recursive mutex 会出现死锁比较便于 debug,如果使用 recursive mutex 则会正常执行。

2.1.1 死锁

一个经典的死锁模型:带锁的对象 A 有一个可以调用 B 的方法,带锁的对象 B 有一个可以调用 A 的方法,有两个线程 t1 、 t2 分别执行两个方法,线程 t1 执行 A 的方法,先将自己加锁,然后 t2 线程执行 B 的方法,也将自己加锁, t1 线程继续执行,调用 B 等待 B 解锁,t2 线程也继续执行调用 A,也在等待 A 解锁,两个线程互相等待对方,死锁形成。
在有两个对象互相调用的情况下要考虑这种死锁。

2.2 条件变量(condition variable)

在使用 mutex 的时候我们一般会希望加锁不要阻塞,总是能立刻拿到锁,然后尽快访问数据,用完后尽快解锁。
如果我们需要等待某个条件成立,我们应该使用条件变量(condition variable) ,条件变量顾名思义有一个或者多个线程等待某个布尔值为真,等待别的线程“唤醒”它。条件变量学名管程(monitor)

互斥器和条件变量构成了多线程编程的全部必备同步原语,用它们可以完成任何多线程同步任务,二者不能互相替代。

2.2.1 倒计时 (CountDownLatch)

倒计时是一种常用且易用的同步手段,它主要有两个用途:

  1. 主线程发起多个子线程,等待多个线程都完成一定任务后,主线程才继续执行。
  2. 主线程发起多个子线程,子线程等待主线程执行一些任务后,通知所有子线程开始执行。
    当然也可以使用条件变量来实现这两种同步,不过用倒计时的话,逻辑更清晰。
  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2022-2024 lk
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~

支付宝
微信