• 《Linux多线程服务端编程》笔记


    序言:
    这其实是一份作业。这种形式我认为挺好的,读书和笔记,而且可以自由发挥,只要与计算机操作系统有关。最开始的构想是读一本和 linux 内核有关的书或者是和多线程编程有关的,后来根据实用性还是选择了和多线程编程有关的书。在挑选书籍的时候,正巧看到很多人推荐陈硕的《Linux多线程服务端编程》,真是完美满足我的需求,多线程编程的实际运用,并且还是网络相关,又有 C++ 网络库的内容,都正好是我需要的。
    采用的形式是我比较习惯的博客的形式,大概就是把原本的一些重点内容摘抄并总结,加上了自己的理解,和自己实现的代码。
    最后写的量有点多了。 800 多行的 markdown 文件 ,40k 的大小。我也就写了第一章节,也就是和多线程直接相关的章节。后面和网络库相关、工程相关还有杂谈的部分就没写了。

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

    1.1 线程安全

    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<T> / weak_ptr<T> 。

    • 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.7 shared_ptr 的线程安全

    shared_ptr 本身是线程安全的,即它的引用计数本身是安全且无锁的,但对象的读写操作不是百分百线程安全的。
    要在多个线程中同时访问同一个shared_ptr,正确的做法是用 mutex 保护。
    另外,为了性能考虑应尽量应该减小加锁的区域。
    练习解答:

    void write(){
    	shared_ptr<Foo> newPtr(new Foo);
    	{
    		shared_ptr<Foo> tmpPtr; 
    		{
    			MutexLockGuard lock(mutex);
    			tmpPtr = globalPtr;			//将globalPtr拷贝给tmpPtr,此时计数器为2
    			globalPtr = newPtr;			//此时计数器为1
    		}
    	}//globalPtr指向的Foo对象在离开了这层作用域后才销毁
    	doit(newPtr);
    }
    
    

    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<void>>,来把对象的析构都移动到一个专用的线程。

    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. 主线程发起多个子线程,子线程等待主线程执行一些任务后,通知所有子线程开始执行。
      当然也可以使用条件变量来实现这两种同步,不过用倒计时的话,逻辑更清晰。

    2.3 不要使用读写锁和信号量

    2.3.1 读写锁

    • 从正确性来讲,容易犯的错误是在持有 read lock 的时候修改了共享数据,这种情况通常发生在程序的维护阶段,把 read lock 的程序调用了会修改数据的函数。
    • 从性能三来讲,读写锁不一定比 mutex 要高效,如果临界区小,锁竞争不激烈,那么 mutex 往往更快。
    • 通常 reader lock 是可重入的,writer lock 是不可重入的,为了防止 writer 饥饿 writer lock 通常会阻塞后来的 reader lock ,因此 reader lock 重入的时候可能死锁。
    • 在最求低延迟读取的场合也不适用读写锁。

    2.3.2 信号量

    条件变量配合互斥器可以完全替代其功能,而且更不易用错

    2.4 线程安全的 Singleton 实现

    Singleton (单例模式)顾名思义,Singleton 保证了程序中同一时刻最多存在该类的一个对象。
    Singleton 一般有两种实现方式:

    • eager initialization (饿汉)
      • 定义:
        • eager initialization 顾名思义就是在进入程序时直接实例化。
      • 优点:
        • 不用考虑多线程安全,因为即使是多线程程序,在进入的时候一般都是单线程。
        • 因为预先创建好了,所以调用时反应速度快。
      • 缺点:
        • 资源效率,所有实例在程序开始时创建,可能会造成卡顿。
    • lazy initialization (懒汉)
      • 定义:
        • lazy initialization 顾名思义,等到要用的时候再实例化。
      • 优点:
        • 资源利用率高,要用的时候再实例化,很好的节省了资源。
      • 缺点:
        • 在多线程的情况下容易产生线程安全问题。
        • 第一次加载时不够快。

    这里主要讲讲 lazy initialization 的线程安全实现。
    以下是一个 Singleton 的实现。

    template<typename T>
    class Singleton {
    public:
    	static T &instance() {
    		if (!instance_) {
    			instance_ = new T();
    		}
    		return *instance_;
    	}
    private:
    	Singleton()=default;
    	Singleton(const Singleton&) = delete;
    	Singleton &operator=(const Singleton&) = delete;
    	~Singleton() = default;
    	static T * instance_;
    };
    
    template<typename T> 
    T* Singleton<T>::instance_ = nullptr;
    

    这个 Singleton 实现了它的基本功能,在第一次调用 instance 的时候构造对象,并且只构造一次,但很容易看出它是线程不安全的,如果有多个线程同时调用 instance 的时候,有可能会构造多个对象,造成内存泄漏。当然这个代码还有一个问题,就是 new 的对象没释放,也会造成内存泄漏,但由于这时一个 Singleton 就是一个长期存在直到系统关闭才销毁的对象,所以没用必要,当然解决这个问题也简单,可以使用 shared_ptr 或者 静态的嵌套类对象。

    //将nstance修改为这样
    static T &instance() {
    	if (!instance_) {
    		Sleep(1000);	//为了稳定出现内存泄漏
    		instance_ = new T();
    	}
    	return *instance_;
    }
    // 运行两个线程,在vs的
    void f() {
    	Singleton<bitset<10000000>>::instance();
    }
    void vf() {
    }
    void test0() {
    	thread t1(f);	//调用instance
    	thread t2(vf);	//空进程,用于控制变量
    	t1.join();
    	t2.join();
    	return 0;
    }
    
    void test1() {
    	thread t1(f);	//调用instance
    	thread t2(f);	//同时调用instance
    	t1.join();
    	t2.join();
    	return 0;
    }
    

    运行 test0 的代码,增加的内存大概是 2 MB 运行 test1 的代码内存增加大概是 4 MB 很明显出现了内存泄漏,instance 申请了两次内存。
    为了处理线程安全的问题,很容易想出加锁解决。比如:

    template<typename T>
    class Singleton {
    public:
    	static T &instance() {
    		lock_guard<mutex> lock(mutex_);
    		if (!instance_) {
    			instance_ = new T();
    		}
    		return *instance_;
    	}
    private:
    	Singleton()=default;
    	Singleton(const Singleton&) = delete;
    	Singleton &operator=(const Singleton&) = delete;
    	~Singleton() = default;
    	static mutex mutex_;
    	static T * instance_;
    };
    
    template<typename T> 
    T* Singleton<T>::instance_ = nullptr;
    template<typename T>
    mutex Singleton<T>::mutex_;
    
    

    2.4.1 DCL(Double Checked Locking)

    但加锁的开销不小,每一次获取数据都要加一次锁属实是不明智,因为这个函数实际上只有第一次访问才会造成 race condition ,自然有人就想到了这种优化方法:

    static T &instance() {
    	
    	
    	if (!instance_) {
    		lock_guard<mutex> lock(mutex_);
    		instance_ = new T();
    	}
    	
    	return *instance_;
    }
    

    但很明显这种优化方法是错误的,一样会造成 race condition ,这时我们可以采用 DCL(Double Checked Locking),顾名思义两次检查。

    static T &instance() {
    	if (!instance_) {
    		lock_guard<mutex> lock(mutex_);
    		if (!instance_) {
    			instance_ = new T();
    		}
    	}
    	return *instance_;
    }
    

    这种方式看似高枕无忧了,实际上很长时间,人们也认为这种方式是正确的。但是后来有人指出由于乱序执行(包括编译乱序执行乱序,一个是编译器层面的一个是核心层面)的影响,DCL 也是靠不住的。

    instance_ = new T(); 	//这个操作实际上是3个步骤
    //如下
    tmp= new (sizeof(T));	//申请内存
    new(tmp) T();			//构造
    instance_ =tmp;			//赋值
    

    是结合之前的指令重排可以知道,编译器并不会被约束去执行这些步骤,很多时候第二步和第三步会交换,也就是先给 instance_ 赋值然后再构造。这时候如果还没有进行构造时线程被挂起,另一个线程访问单例就会认为 instance_ 已经构造完毕进而使用了未构造的对象,我们的程序就会 crash 。那么怎么写一个线程安全的双重检查?这需要用到内存屏障(memory barriers)或者原子操作
    在 C++11 中这两种方式均被封装进标准库中可以直接调用,但先不讨论这两种方法,实际上如果使用 C++11 我们可以用一种更优美的方式实现 Singleton 。

    2.4.2 Meyers' Singleton

    template<typename T>
    class Singleton {
    public:
    	static T &instance() {
    		static T instance_;
    		return *instance_;
    	}
    private:
    	Singleton()=default;
    	Singleton(const Singleton&) = delete;
    	Singleton &operator=(const Singleton&) = delete;
    	~Singleton() = default;
    };
    

    该 Singleton 使用一个静态变量来储存数据,非常的简单明了,在 C++ 11 中,它是线程安全的。根据标准,§6.7 [stmt.dcl] p4:

    If control enters
    the declaration concurrently while the variable is being initialized, the concurrent execution shall wait for completion of the initialization.

    gcc 和 vs 对该特性(动态初始化和并发销毁,也称为 msdn 上的 magic statics)的支持如下:

    • Visual Studio:自 Visual Studio 2015 以来支持 。
    • GCC:自 GCC 4.3 起支持。】

    2.4.3 (linux)pthread_once / std::call_once 实现

    template<typename T>
    class Singleton {
    public:
    	static T &instance() {
    		call_once(onceFlag_, [&]{instance_ = new T(); });
    		return *instance_;
    	}
    private:
    	Singleton()=default;
    	Singleton(const Singleton&) = delete;
    	Singleton &operator=(const Singleton&) = delete;
    	~Singleton() = default;
    	static once_flag onceFlag_;
    	static atomic<T *> instance_;
    };
    
    template<typename T> 
    atomic<T *> Singleton<T>::instance_ = nullptr;
    template<typename T>
    once_flag Singleton<T>::onceFlag_;
    
    template<typename T>
    class Singleton : boost::noncopyable {
    public:
        static T& instance() {
            pthread_once(&ponce_, &Singleton::init);
            return *value_;
        }
        
        static void init() {
            value_ = new T();
        }
    private:
        static pthread_once_t ponce_;
        static T* value_;
    };
     
    template<typename T>
    pthread_once_t Singleton<T>::ponce_ = PTHREAD_ONCE_INIT;
     
    template<typename T>
    T* Singleton<T>::value_ = nullptr;
    
    

    (linux)pthread_once / std::call_once 函数的特点是只会运行一次,并且线程安全,pthread_once 是 linux 自己线程库的东西,std::call_once 是 C++11 线程库的东西。

    2.5 sleep 不是同步原语

    sleep 只能出现在测试代码之中,正常的执行中,如果需要等待一段已知的时间,应该往 event loop 里注册一个 timer,然后在 timer 的回调函数中继续干活,如果是等待某个事情发生应该使用条件变量或者 IO 事件回调,不能使用 sleep。使用 sleep 低效且浪费资源。

    2.6 借 shared_ptr 实现 copy-on-write

    2.6.1 用普通 mutex 替换读写锁

    读写冲突的对象,我们可以使用 shared_ptr 来实现类似读写锁的机制,将数据使用 shared_ptr 储存,但读取的时候使用一个栈上的局部 shared_ptr 变量当作“观察者”,将它指向数据,使数据的计数器增加,这时只需要加锁构建“观察者”的那部分,缩小了临界区,并且读操作不会互相冲突,增加 read 的速度,减少 read 的延时。

    void read(){
    	shared_ptr<DataType> obs;
    	{
    		lock_gruad<mutex> lock(mutex_);
    		obs=data_;
     	}
    	obs->doit();
    }
    

    write 在写入的时候判断一次数据是否正在被读取,也就是 shared_pte 的计数器是否为一,如果不为一就拷贝一份并且替代原本的数据,再进行修改。这个方法的缺点是 write 需要额外开销,如果进行频繁的写操作时内存中可能会出现多个数据的副本(由于是使用 shared_ptr 不会内存泄漏),适合多读少写的需求情况。

    
    void write(const WriteType &x){	
    	lock_gruad<mutex> lock(mutex_);
    	if(!data_.unique()){
    		data_.reset(new DataType(*data_));
    	}
    	data_.write(x);
    }
    
    

    2.7 归纳总结

    • 线程同步的四项原则,尽量使用高层同步设施(线程池、队列、倒计时)。
    • 使用普通互斥器和条件变量完成剩余的同步内容,采用 RAII 惯用手法和 Scoped Locking。
    • 读写冲突的时候可以复制并替换原本内容来解决。

    3 多线程服务器的适用场合与常用编程模型

    3.1 进程与线程

    3.1.1 进程

    粗略的说,一个进程是“内存中正在运行的程序”,每一个进程都有自己的独立地址空间。《Erlang程序设计》把“进程”比喻为“人”,为我们提供了个思考框架。
    每一个人有自己的记忆(内存),人与人之间通过谈话(消息传递)来交流,谈话可以是面对面(同一台机器中),也可以是通过电话(网络),面谈可以知道他是不是死了,而电话只能根据周期性的心跳来判断他是否活着。
    有了这个比喻,设计分布式系统时就可以采用“角色扮演”形式思考了:

    • 容错      万一人死了
    • 扩容      新人招募
    • 负载均衡    把甲的活分担给别人
    • 退休      甲要去培训 or 治病(修bug),先别派任务,等他做完手上的事情就重启

    3.1.2 线程

    线程的特点是可以共享地址空间,可以更高效的共享数据。
    多线程的价值是为了更好的发挥多核心处理器的效能,在单核时代,多线程没有多大价值。
    Alan Cox 说过:

    A Computer is a state machine. Threads are for people who can't program state machines.
    (计算机是一个状态机,线程是给那些不能编写状态机程序的人准备的)

    如果只有一块 CPU、一个执行单元,那么的确和 Alan Cox 所说的,按状态机的思路写程序是最高效的。

    3.2 单线程服务器的常用编程模型

    在高性能网络程序中,使用的最广泛的是“non-blocking IO + IO multiplexing”(非阻塞式 IO + IO 多路复用)这种模型,即 Reactor 模式,如:

    • lighttpd,单线程服务器。
    • libevent,libev。
    • ACE,Poco C++ libraries。
    • Java NIO。
    • POE(perl)。
    • Twisted(python)

    相反 Boost.Asio 和 Windows I/O Completion Ports 实现了 Proactor 模式。应用面似乎要窄一些。

    3.2.1 Reactor

    • 实现:
      • Reactor 模型中。程序的基本结构就是一个事件循环(event loop),以事件驱动和事件回调的方式实现的业务逻辑。
    • 优点:
      • 编程不难。
      • 效率不错。
      • 对 IO 密集形的应用是不错的选择。
    • 缺点:
      • 要求事件回调函数必须是非阻塞式。
      • 容易割裂业务逻辑,相对不容易理解和维护。

    3.3 多线程服务器常用编程模型

    常见的模型大概有:

    • 每一个请求创建一个线程,使用阻塞式 IO 操作。
    • 使用线程池,同样使用阻塞式 IO 操作。
    • 使用 non-blocking IO + IO multiplexing。
    • Leader / Follower 等高级模式。

    默认情况推荐使用第三种,即 non-blocking IO + one loop per thread 模式。

    3.3.1 one loop per thread

    在这种模型下,每一个 IO 线程有一个 event loop(Reactor),用于处理读写的定时事件。
    这种方法的好处是:

    • 线程数目固定,不会频繁创建和销毁。
    • 可以方便的在线程间调配负载。
    • IO 事件发生的线程是固定的,同一个 TCP 连接不比考虑事件并发。

    Event loop 代表了线程的主循环,需要让哪一个线程干活就把 timer 或 IO channel 注册到哪一个线程的 loop 里即可。

    3.3.2 线程池

    对于光有计算任务没有 IO 任务的线程,使用 event loop 有点浪费,使用一种补充方案,即用 blocking queue 实现的任务队列。

    template<typename T>
    class BlockingQueue {
    public:
    	BlockingQueue() = default;
    	BlockingQueue(const BlockingQueue&) = delete;
    	BlockingQueue& operator=(const BlockingQueue&) = delete;
    	void push(const T& val) {
    		lock_guard<mutex> lock(mutex_);
    		data_.push(val);
    		cond_.notify_one();
    	}
    	T pop() {
    		unique_lock<mutex> lock(mutex_);
    		while (data_.empty()) {
    			cond_.wait(lock);
    		}
    		T tmp = data_.front();
    		data_.pop();
    		return tmp;
    	}
    	
    private:
    	queue<T> data_;
    	mutex mutex_;
    	condition_variable cond_;
    };
    
    
    
    
    class TharedPool {
    public:
    	using Functor = function<void()>;
    	TharedPool(): running_(true), taskQueue_(){
    		for (int i = 0; i < num_of_computing_thread; ++i) {
    			threads_.push_back(thread(&workerThread,this));
    		}
    	}
    	void post(Functor functor) {
    		taskQueue_.push(functor);
    	}
    	
    	void stop() {
    		running_ = false;
    		for (int i = 0; i < threads_.size(); ++i) {
    			post([] {});
    		}
    		for (int i = 0; i < threads_.size(); ++i) {
    			threads_[i].join();
    		}
    	}
    
    private:
    	static void workerThread(TharedPool* tp) {
    		while (tp->running_) {
    			Functor task = tp->taskQueue_.pop();
    			task();
    		}
    	}
    
    	BlockingQueue<Functor> taskQueue_;
    	vector<thread> threads_;
    	bool running_;
    };
    
    

    手动实现的阻塞队列来实现的简易线程池,要使用的时候使用前文的 Singleton 包装。
    除了任务队列,还可以使用 blocking queue 来实现生产者消费者队列,当然里面存储的就不是可调用对象了,而是数据。

    3.3.3 推荐模式

    总结,推荐使用 one(event) loop per thread + thread pool。

    • event loop 用作 IO multiplexing ,配合 non-blocking IO 和定时器。
    • thread pool 用于计算,具体可以是任务队列和生产者消费者队列。

    3.4 进程间通信只用 TCP

    Linux 下进程间通信(IPC)的方式数不胜数:

    • 匿名管道(pipe)
    • 具名管道(FIFO)
    • POSIX 消息队列
    • 共享内存
    • 信号 (signals)
    • Sockets

    同步原语也有很多

    • 互斥器
    • 条件变量
    • 读写锁
    • 文件锁
    • 信号量

    贵精不贵多,进程间通信首选 Sockets ,好处在于:

    • 可以跨主机,有伸缩性。
    • 双向通信,方便。
    • TCP port 由操作系统自动回收,即使程序意外退出也不会产生垃圾。
    • TCP port 由程序独占,防止程序重复启动。
    • 两个 TCP 通信,如果一个崩溃了,可以通过心跳,快速感应到另一个程序崩溃。
    • 可记录,可重现。
    • 跨语言。
    • 实现简单。

    3.5 多线程服务器的适用场合

    3.5.1 处理并发连接

    开发服务端程序的一个基本任务是处理并发连接,主要有两种方式:

    • 当“线程”廉价时,一台机器可以创建远高与 CPU 数目的“线程”。这时一个线程只处理一个 TCP 连接,通常使用阻塞 IO。
    • 但“线程”很宝贵的时候,通常创建和 CPU 数目相当的线程。这时一个线程要处理多个 TCP 连接上的 IO,通常使用非阻塞 IO 和 IO multiplexing 。

    3.5.1 处理模式

    在一个多核机器上提供一种服务或者执行一个任务,可用的模式有:

    1. 运行一个单线程的进程
      • 这种模式是不可伸缩的,不能发挥多核心的优势
    2. 运行一个多线程的进程
      • 这种模式被很多人鄙视,认为多线程难写,并且比起模式 3 没什么优势。
    3. 运行多个单线程的进程
      • 3a 简单的把模式 1 中的进程运行多份。
      • 3b 主进程 + worker 进程。
    4. 运行多个多线程的进程
      • 更是被千夫所指,不但没有结合 2 和 3 的优点,反而汇集了二者的缺点。

    3.5.2 实例

    比方说:使用速率为 50 MB / s 的数据压缩库、在进程创建和销毁的开销是 800 us、线程创建和销毁的开销是 50 us 的前提下,如何执行压缩任务:

    • 如果要偶尔压缩 1 GB 的文本文件,预计的运行时间是 20 s ,那么起一个进程去做是合理的,因为启动进程的开销远远小于实际任务开销。
    • 如果要经常压缩 500 KB 的文本文件,预计的运行时间是 10 ms ,那么每次起一个进程似乎有点浪费,可以单独起一个线程去做。
    • 如果要频繁压缩 10 KB 的文本文件,预计的运行时间是 200 us ,那么每次起一个线程也很浪费,不如交给现在的线程搞定,或者用线程池,避免阻塞当前线程。

    3.5.3 必须用单线程的场合

    有两种场合必须使用单线程:

    1. 程序可能会 fork(2)。
      • 这么做会出现很多麻烦,而且没有一定要这么做的理由。
    2. 限制 CPU 占用率。
      • 多核心机器中,单线程程序最多只占用一个核心。

    3.5.4 单线程程序的优缺点

    • 优点:
      • 简单,程序的结构一般是一个基于 IO multiplexing 的 event loop。
      • 单核心下的性能优势。
    • 缺点:
      • event loop 是非抢占的,容易造成优先级反转,没办法控制优先级。
      • 多核心下 CPU 利用率低。

    3.5.5 适用多线程程序的场景

    多线程的应用场景是:提高响应速度,让 IO 和计算互相重叠。
    一个程序要做成多线程大概要满足:

    1. 有多个 CPU 可用。
    2. 线程中有共享数据,如果没有共享数据,那使用多进程单线程模型就好。
    3. 共享的数据可以修改,而不是静态的常量表。如果不能修改,那我们可以直接使用共享内存。
    4. 提供非均质的服务,即,事务间有优先级。
    5. 延迟和吞吐量同样重要。
    6. 利用异步操作。
    7. 可扩展。
    8. 具有可预测的性能。
    9. 多线程能有效的划分责任与功能,让每一个线程的逻辑比较简单,任务单一,便于编码。

    线程的分类:

    1. IO线程,这里线程的主循环是 IO multiplexing,也可以做一些简单的计算,比如消息的编码或者解码。
    2. 计算线程,这类线程的主循环是 blocking queue,一般要避免任何阻塞操作。
    3. 第三方库使用的线程。

    3.6 答疑

    3.6.1 Linux 能同时启用多少线程?

    32 位 Linux 300 左右是上限,64 位就更多了,实际上在一台机器中最多只用到几十个用户线程。

    3.6.2 多线程如何增加效率的?

    线程不能减少工作量,反而会增加工作量,它做到的是资源的统筹调配,从而使各个资源的利用率提升,来提高效率。

    3.6.3 什么是线程池大小的阻抗匹配原则?

    如果线程池中线程在执行任务时间,密集计算所占时间比例为 P (0 \(\lt\) P \(\le\) 1),而系统一共有 C 个 CPU ,为了让 CPU 跑满又不过载, 线程池的大小 T = C / P 。考虑到 P 不会很准确,T 的最佳值可上下浮动 50% ,当 P 小于 0.2 时,公式不适用,取一个固定的倍数会更好,比如 T = 5 * C 。

    4 C++ 多线程系统编程精要

    学习多线程最大的思维方式转变有两点:

    1. 当前线程随时会被切换出去。
    2. 多线程程序中事件发生的顺序不再有全局统一的先后关系。

    4.1 基本线程原语的选用

    11 个最基本的 Pthreads 函数是:

    • 2 个:线程的创建和等待结束。封装为 muduo::Thread。
    • 4 个:mutex 的创建、销毁、加锁、解锁。封装为 muduo::MutexLock。
    • 5 个:条件变量的创建、销毁、等待、通知、广播。封装为 muduo::Condition。

    除此之外还有一些其他原语,可以酌情使用:

    • pthread_once,封装为 muduo::Singlenton<T>。其实不如直接使用全局变量。
    • pthread_key*,封装为 muduo::ThreadLocal<T>。可以考虑用 __thread 替换。

    不推荐使用的:

    • pthread_rwlock。
    • sem_*。
    • pthread_{cancel,kill}。

    4.2 C/C++ 系统库的线程安全性

    • 一个线程安全的基本原则:如果一个对象自始至终只被一个线程用到,那么它就是安全的。
    • 另一个事实标准是:共享对象的 read-only 操作是安全的,前提是不能有并发写的操作。即多个线程读取一个常量对象是安全的。一旦有了写操作,那么读操作也不安全了。
    • 绝大多的泛型算法都是安全的,因为这些都是无状态函数。
    • C++ 的 iostream 不是线程安全的,printf 是线程安全的,但相当于使用了全局锁,恐怕不太高效。

    4.3 Linux 的线程标识

    在 Linux 上建议使用 gettid(2) 系统调用的返回值作为线程 id,这么做的好处是:

    • 它的类型是 pid_t,通常是一个小整数。
    • 它直接表示内核的任务调度 id。
    • 在其他系统工具中也容易定位到具体某一个线程。
    • 任何时刻都是全局唯一的、
    • 0 是非法值。

    muduo::CurrentThread::tid() 使用了 __thread 变量缓存 gettid(2) 的返回值,更加高效。

    4.4 线程的创建与销毁的守则

    4.4.1 线程的创建

    几条简单的原则:

    • 程序库不应该在未提前告知的情况下创建自己的“背景线程”。
    • 尽量使用相同的方法创建线程。
    • 在进入 main() 之前不要启动线程。
    • 程序中线程的创建最好能在初始化阶段全部完成。

    4.4.2 线程的销毁

    线程的销毁一般有几种方式:

    • 自然死亡。线程从主函数返回,正常退出。
    • 非正常死亡。从线程主函数抛出异常或线程触发 segfault 信号等非法操作。
    • 自杀。在线程中调用函数退出线程。
    • 他杀。其他线程调用函数强制终止某个线程。

    线程正常退出的方式只有一种,即自然死亡。任何从外部强行终止线程的做法和想法都是错误的,不应该使用 pthread_cancel 或者 exit(3)。
    如果能做到程序中线程的创建在初始化阶段全部完成,则线程是不必销毁的,伴随进程一直执行,彻底避开了线程销毁的一系列问题。

    4.5 善用 __thread 关键字

    • 是什么?
      • __thread 是 GCC 内置的线程局部存储设施。
    • 怎么用?
      • 使用 __thread 可以修饰 POD 类型的变量,不能修饰 class 类型,因为无法自动调用构造和析构。
      • __thread 只能修饰全局和函数内静态变量。
    • 有什么用?
      • __thread 变量是每一个线程都有一份独立的实体,各线程不会互相干扰。
      • 还可以修饰那些“值可能会变,带有全局性,但又不值得用锁保护”的变量。

    4.6 多线程与 IO

    多线程共用一个 IO 没意义,难以安全实现,并且不会增加效率,每一个文件操作符只由一个线程操作。
    有两个例外:

    1. 对于磁盘文件,在有必要的时候多个线程可以同时调用 pread(2) / pwrite(2) 来读写一个文件。
    2. 对于 UDP,在适当条件下,可以多线程同时读写一个 UDP 文件描述符。

    4.7 用 RAII 包装文件描述符

    程序不要只记住文件描述符,要使用 RAII 的方式包装文件描述符,并且使用 shared_ptr ,来确保对象的生命周期。
    linux 的文件描述符在程序刚启动的时候,0 是标准输入,1 是标准输出,2 是标准错误,如果你新打开一个文件,那么它的文件描述符会是 3,如果你关闭这个文件,然后再打开它,还是 3。因为,POSIX 标准中规定,每一次打开文件的时候要使用当前最小可用文件描述符。

    4.8 RAII 与 fork()

    某些资源在使用 fork() 后不会复制,这会导致包装那种资源的对象,析构两次。在程序设计开始的时候就要考虑是否能用 fork() 了,不要在没有做好使用 fork() 准备的程序中使用 fork()。

    4.9 多线程与 fork()

    别在多线程程序使用 fork(),唯一安全的做法是:使用 fork() 后直接调用 exec() 来执行另一个程序,彻底隔断与父进程的联系。

  • 相关阅读:
    Qt编程之qrc文件的链接
    Visual Studio中的lib的链接顺序
    c++语言中的遍历
    转载:使用 OpenCV 识别 QRCode
    GreenOpenPaint的实现(四)放大缩小处理滚动事件
    GreenOpenPaint的实现(六)图片的保存和打开
    GreenOpenPaint的实现(五)矩形框
    GreenOpenPaint的实现(三)添加标尺
    GreenOpenPaint的实现(二)打开显示图片
    GreenOpenPaint的实现(一)基本框架
  • 原文地址:https://www.cnblogs.com/komet/p/16306822.html
Copyright © 2020-2023  润新知