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

《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 有两个很大的问题。

如何知道该对象已经被销毁?

该对象将何时销毁?

如,有两个指针 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++ 中可能出现的内存问题大致有这么几个方面:

缓冲区溢出

空悬指针/野指针

重复释放

内存泄漏

不配对的 new[] / delete

内存碎片

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

缓冲区溢出:使用std::vector / std::string 或者自己编写 Buffer class 来管理缓冲区,自动记住用缓冲区的长度,并通过成员函数而不是裸指针来修改缓冲区。

空悬指针/野指针:使用 shared_ptr / weak_ptr 。

重复释放:用 shared_ptr ,对象只会析构一次。

内存泄漏:用 shared_ptr ,对象析构会自动释放内存。

不配对的 new[] / delete:将 new[] 统统替换为 std::vector / scoped_array。

现代的 C++ 程序中一般不会出现 delete ,资源都是通过对象进行管理的,不需要程序员操心。

需要注意的一点是,shared_ptr / weak_ptr 都是值语义,几乎不会有下面这种用法:

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

1.7 shared_ptr 的线程安全

shared_ptr 本身是线程安全的,即它的引用计数本身是安全且无锁的,但对象的读写操作不是百分百线程安全的。

要在多个线程中同时访问同一个shared_ptr,正确的做法是用 mutex 保护。

另外,为了性能考虑应尽量应该减小加锁的区域。

练习解答:

void write(){

shared_ptr newPtr(new Foo);

{

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

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 x(new int, f);

class Stock{/*...*/};

class StockFactory{

void deleteStock(Stock *);

/*...*/

};

shared_ptr 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{/*..*/}

另外要注意的是,为了使用 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& ,Stock*);

/*...*/

};

shared_ptr pStock;

pStock.reset(new Stock(key),boost::bind(&StockFactory::weakDeleteCallback,boost::weak_ptr(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 也更容易保证程序的正确性。

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

首要原则是降低共享对象,一个对象能不暴露给其他线程就不暴露,实在要暴露,优先考虑 immutable 对象,实在不行才暴露可修改的对象,并且使用同步措施充分进行保护。

其次是使用高级的并发编程构件,如 TaskQueue 、 Producer-Consumer Queue 、CountDownLatch 等等。

最后不得已必须使用底层同步原语时,只用非递归的互斥器和条件变量,慎用读写锁,不要用信号量。

除了使用 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)

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

主线程发起多个子线程,等待多个线程都完成一定任务后,主线程才继续执行。

主线程发起多个子线程,子线程等待主线程执行一些任务后,通知所有子线程开始执行。

当然也可以使用条件变量来实现这两种同步,不过用倒计时的话,逻辑更清晰。

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

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

T* Singleton::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>::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

class Singleton {

public:

static T &instance() {

lock_guard 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

T* Singleton::instance_ = nullptr;

template

mutex Singleton::mutex_;

2.4.1 DCL(Double Checked Locking)

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

static T &instance() {

if (!instance_) {

lock_guard lock(mutex_);

instance_ = new T();

}

return *instance_;

}

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

static T &instance() {

if (!instance_) {

lock_guard 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

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

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 instance_;

};

template

atomic Singleton::instance_ = nullptr;

template

once_flag Singleton::onceFlag_;

template

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

pthread_once_t Singleton::ponce_ = PTHREAD_ONCE_INIT;

template

T* Singleton::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 obs;

{

lock_gruad lock(mutex_);

obs=data_;

}

obs->doit();

}

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

void write(const WriteType &x){

lock_gruad 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

class BlockingQueue {

public:

BlockingQueue() = default;

BlockingQueue(const BlockingQueue&) = delete;

BlockingQueue& operator=(const BlockingQueue&) = delete;

void push(const T& val) {

lock_guard lock(mutex_);

data_.push(val);

cond_.notify_one();

}

T pop() {

unique_lock lock(mutex_);

while (data_.empty()) {

cond_.wait(lock);

}

T tmp = data_.front();

data_.pop();

return tmp;

}

private:

queue data_;

mutex mutex_;

condition_variable cond_;

};

class TharedPool {

public:

using Functor = function;

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 taskQueue_;

vector 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 处理模式

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

运行一个单线程的进程

这种模式是不可伸缩的,不能发挥多核心的优势

运行一个多线程的进程

这种模式被很多人鄙视,认为多线程难写,并且比起模式 3 没什么优势。

运行多个单线程的进程

3a 简单的把模式 1 中的进程运行多份。

3b 主进程 + worker 进程。

运行多个多线程的进程

更是被千夫所指,不但没有结合 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 必须用单线程的场合

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

程序可能会 fork(2)。

这么做会出现很多麻烦,而且没有一定要这么做的理由。

限制 CPU 占用率。

多核心机器中,单线程程序最多只占用一个核心。

3.5.4 单线程程序的优缺点

优点:

简单,程序的结构一般是一个基于 IO multiplexing 的 event loop。

单核心下的性能优势。

缺点:

event loop 是非抢占的,容易造成优先级反转,没办法控制优先级。

多核心下 CPU 利用率低。

3.5.5 适用多线程程序的场景

多线程的应用场景是:提高响应速度,让 IO 和计算互相重叠。

一个程序要做成多线程大概要满足:

有多个 CPU 可用。

线程中有共享数据,如果没有共享数据,那使用多进程单线程模型就好。

共享的数据可以修改,而不是静态的常量表。如果不能修改,那我们可以直接使用共享内存。

提供非均质的服务,即,事务间有优先级。

延迟和吞吐量同样重要。

利用异步操作。

可扩展。

具有可预测的性能。

多线程能有效的划分责任与功能,让每一个线程的逻辑比较简单,任务单一,便于编码。

线程的分类:

IO线程,这里线程的主循环是 IO multiplexing,也可以做一些简单的计算,比如消息的编码或者解码。

计算线程,这类线程的主循环是 blocking queue,一般要避免任何阻塞操作。

第三方库使用的线程。

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++ 多线程系统编程精要

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

当前线程随时会被切换出去。

多线程程序中事件发生的顺序不再有全局统一的先后关系。

4.1 基本线程原语的选用

11 个最基本的 Pthreads 函数是:

2 个:线程的创建和等待结束。封装为 muduo::Thread。

4 个:mutex 的创建、销毁、加锁、解锁。封装为 muduo::MutexLock。

5 个:条件变量的创建、销毁、等待、通知、广播。封装为 muduo::Condition。

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

pthread_once,封装为 muduo::Singlenton。其实不如直接使用全局变量。

pthread_key*,封装为 muduo::ThreadLocal。可以考虑用 __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 没意义,难以安全实现,并且不会增加效率,每一个文件操作符只由一个线程操作。

有两个例外:

对于磁盘文件,在有必要的时候多个线程可以同时调用 pread(2) / pwrite(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() 来执行另一个程序,彻底隔断与父进程的联系。

相关推荐

饥荒海滩怎么去 海滩有什么物品
beat365手机网址

饥荒海滩怎么去 海滩有什么物品

🕒 08-08 👀 5588
手机照相机打不开是怎么回事
英国365bet官方网

手机照相机打不开是怎么回事

🕒 08-16 👀 1920