C++多线程程序设计经历ITeye - 凯发娱乐

C++多线程程序设计经历ITeye

2019年02月26日15时24分36秒 | 作者: 海超 | 标签: 线程,程序,运用 | 浏览: 2366

转自:http://blog.csdn.net/Solstice/article/details/5307710

本文首要讲我个人在多线程开发方面的一些浅显经历。总结了一两种常用的线程模型,概括了进程间通讯与线程同步的最佳实践,以期用简略规范的方法开发多线程程序

文中的“多线程服务器”是指运转在 Linux 操作体系上的独占式网络运用程序。硬件渠道为 Intel x64 系列的多核 CPU,单路或双路 SMP 服务器(每台机器总共具有四个核或八个核,十几 GB 内存),机器之间用百兆或千兆以太网衔接。这大约是现在民用 PC 服务器的干流装备。

本文不触及 Windows 体系,不触及人机交互界面(不管命令行或图形);不考虑文件读写(往磁盘写 log 在外),不考虑数据库操作,不考虑 Web 运用;不考虑低端的单核主机或嵌入式体系,不考虑手持式设备,不考虑专门的网络设备,不考虑高端的  =32 核 Unix 主机;只考虑 TCP,不考虑 UDP,也不考虑除了局域网络之外的其他数据收发方法(例如串并口、USB口、数据收集板卡、实时操控等)。

 

有了以上这么多约束,那么我即将谈的“网络运用程序”的根本功用能够概括为“收到数据,算一算,再发出去”。在这个简化了的模型里,好像看不出用多线程的必要,单线程应该也能做得很好。“为什么需求写多线程程序”这个问题简略引发口水战,我放到另一篇博客里评论。请答应我先假定“多线程编程”这一布景。

 

“服务器”这个词有时指程序,有时指进程,有时指硬件(不管虚拟的或真实的),请留意按上下文差异。其他,本文不考虑虚拟化的场景,当我说“两个进程不在同一台机器上”,指的是逻辑上不在同一个操作体系里运转,尽管物理上或许坐落同一机器虚拟出来的两台“虚拟机”上。

 

本文假定读者现已有多线程编程的常识与经历,这不是一篇入门教程。

 

本文承蒙 Milo Yip 先生审读,在此深表谢意。当然,文中任何过错职责均在我。

 

 

 

目  录

1 进程与线程 2

2 典型的单线程服务器编程模型 3

3 典型的多线程服务器的线程模型 3

One loop per thread 4

线程池 4

概括 5

4 进程间通讯与线程间通讯 5

5 进程间通讯 6

6 线程间同步 7

互斥器 (mutex) 7

跑题:非递归的 mutex 8

条件变量 10

读写锁与其他 11

封装 MutexLock、MutexLockGuard 和 Condition 11

线程安全的 Singleton 完成 14

概括 15

7 总结 15

后文预览:Sleep 反形式 16

 

1 进程与线程

“进程/process”是操作里最重要的两个概念之一(另一个是文件),粗略地讲,一个进程是“内存中正在运转的程序”。本文的进程指的是 Linux 操作体系经过 fork() 体系调用发作的那个东西,或许 Windows 下 CreateProcess() 的产品,不是 Erlang 里的那种轻量级进程。

每个进程有自己独立的地址空间 (address space),“在同一个进程”仍是“不在同一个进程”是体系功用区分的重要决议计划点。Erlang 书把“进程”比方为“人”,我觉得十分精当,为咱们供给了一个考虑的结构。

 

每个人有自己的回忆 (memory),人与人经过说话(音讯传递)来沟通,说话既能够是面谈(同一台服务器),也能够在电话里谈(不同的服务器,有网络通讯)。面谈和电话谈的差异在于,面谈能够当即知道对方死否死了(crash, SIGCHLD),而电话谈只能经过周期性的心跳来判别对方是否还活着。

 

有了这些比方,规划分布式体系时能够采纳“人物扮演”,团队里的几个人各自扮演一个进程,人的人物由进程的代码决议(管登陆的、管音讯分发的、管生意的等等)。每个人有自己的回忆,但不知道他人的回忆,要想知道他人的观念,只能经过攀谈。(暂不考虑同享内存这种 IPC。)然后就能够考虑容错(假如有人俄然死了)、扩容(新人半途加进来)、负载均衡(把 a 的活儿挪給 b 做)、退休(a 要修正 bug,先别给他派新活儿,等他做完手上的工作就把他重启)等等各种场景,十分便当。

 

“线程”这个概念大约是在 1993 年今后才渐渐流行起来的,距今不过十余年,比不得有 40 年光芒前史的 Unix 操作体系。线程的呈现给 Unix 添了不少乱,许多 C 库函数(strtok(), ctime())不是线程安全的,需求从头界说;signal 的语意也大为杂乱化。据我所知,最早支撑多线程编程的(民用)操作体系是 Solaris 2.2 和 Windows NT 3.1,它们均发布于 1993 年。随后在 1995 年,POSIX threads 规范树立。

 

线程的特点是同享地址空间,然后能够高效地同享数据。一台机器上的多个进程能高效地同享代码段(操作体系能够映射为相同的物理内存),但不能同享数据。假如多个进程许多同享内存,等所以把多进程程序当成多线程来写,掩耳盗铃。

 

“多线程”的价值,我以为是为了更好地发挥对称多路处理 (SMP) 的效能。在 SMP 之前,多线程没有多大价值。Alan Cox 说过 A computer is a state machine. Threads are for people who cant program state machines. (核算机是一台状况机。线程是给那些不能编写状况机程序的人预备的。)假如只需一个履行单元,一个 CPU,那么的确如 Alan Cox 所说,按状况机的思路去写程序是最高效的,这正好也是下一节展现的编程模型。

 

2 典型的单线程服务器编程模型

UNP3e 对此有很好的总结(第 6 章:IO 模型,第 30 章:客户端/服务器规划范式),这儿不再赘述。据我了解,在高功用的网络程序中,运用得最为广泛的恐怕要数“non-blocking IO + IO multiplexing”这种模型,即 Reactor 形式,我知道的有:

l lighttpd,单线程服务器。(nginx 估量与之相似,待查)

l libevent/libev

l ACE,Poco C++ libraries(QT 待查)

l Java NIO (Selector/SelectableChannel), Apache Mina, Netty (Java)

l POE (Perl)

l Twisted (Python)

相反,boost::asio 和 Windows I/O Completion Ports 完成了 Proactor 形式,运用面好像要窄一些。当然,ACE 也完成了 Proactor 形式,不表。

在“non-blocking IO + IO multiplexing”这种模型下,程序的根本结构是一个工作循环 (event loop):(代码仅为暗示,没有完好考虑各种状况)

while (!done)

{

  int timeout_ms = max(1000, getNextTimedCallback());

  int retval = ::poll(fds, nfds, timeout_ms);

  if (retval   0) {

    处理过错

  } else {

    处理到期的 timers

    if (retval   0) {

      处理 IO 工作

    }

  }

}

当然,select(2)/poll(2) 有许多缺乏,Linux 下可替换为 epoll,其他操作体系也有对应的高功用替代品(搜 c10k problem)。

Reactor 模型的长处很明显,编程简略,功率也不错。不只网络读写能够用,衔接的树立(connect/accept)乃至 DNS 解析都能够用非堵塞方法进行,以进步并发度和吞吐量 (throughput)。关于 IO 密布的运用是个不错的挑选,Lighttpd 便是这样,它内部的 fdevent 结构十分精妙,值得学习。(这儿且不考虑用堵塞 IO 这种次优的计划。)

 

当然,完成一个优质的 Reactor 不是那么简略,我也没有用过坊间开源的库,这儿就不引荐了。

 

3 典型的多线程服务器的线程模型

这方面我能找到的文献不多,大约有这么几种:

1. 每个恳求创立一个线程,运用堵塞式 IO 操作。在 Java 1.4 引进 NIO 之前,这是 Java 网络编程的引荐做法。惋惜伸缩性欠安。

2. 运用线程池,相同运用堵塞式 IO 操作。与 1 比较,这是进步功用的方法。

3. 运用 non-blocking IO + IO multiplexing。即 Java NIO 的方法。

4. Leader/Follower 等高档形式

在默许状况下,我会运用第 3 种,即 non-blocking IO + one loop per thread 形式。
http://pod.tst.eu/http://cvs.schmorp.de/libev/ev.pod#THREADS_AND_COROUTINES

One loop per thread

此种模型下,程序里的每个 IO 线程有一个 event loop (或许叫 Reactor),用于处理读写和守时工作(不管周期性的仍是单次的),代码结构跟第 2 节相同。

 

这种方法的长处是:

l 线程数目根本固定,能够在程序发动的时分设置,不会频频创立与毁掉。

l 能够很便利地在线程间分配负载。

event loop 代表了线程的主循环,需求让哪个线程干活,就把 timer 或 IO channel (TCP connection) 注册到那个线程的 loop 里即可。对实时性有要求的 connection 能够独自用一个线程;数据量大的 connection 能够独占一个线程,并把数据处理使命分摊到另几个线程中;其他非有必要的辅助性 connections 能够同享一个线程。

 

关于 non-trivial 的服务端程序,一般会选用 non-blocking IO + IO multiplexing,每个 connection/acceptor 都会注册到某个 Reactor 上,程序里有多个 Reactor,每个线程至多有一个 Reactor。

多线程程序对 Reactor 提出了更高的要求,那就是“线程安全”。要答应一个线程往其他线程的 loop 里塞东西,这个 loop 有必要得是线程安全的。

 

线程池

不过,关于没有 IO 光有核算使命的线程,运用 event loop 有点糟蹋,我会用有一种弥补计划,即用 blocking queue 完成的使命行列(TaskQueue):

blocking_queue boost::function void()    taskQueue;  // 线程安全的堵塞行列

 

void worker_thread()

{

  while (!quit) {

    boost::function void()  task = taskQueue.take();  // this blocks

    task();  // 在产品代码中需求考虑反常处理

  }

}

用这种方法完成线程池特别简略:

发动容量为 N 的线程池:

int N = num_of_computing_threads;

for (int i = 0; i   ++i) {

  create_thread( worker_thread);  // 伪代码:发动线程

}

运用起来也很简略:

boost::function void()  task = boost::bind( Foo::calc, this);

taskQueue.post(task);

上面十几行代码就完成了一个简略的固定数目的线程池,功用大约相当于 Java 5 的 ThreadPoolExecutor 的某种“装备”。当然,在真实的项目中,这些代码都应该封装到一个 class 中,而不是运用大局目标。其他需求留意一点:Foo 目标的生命期,我的另一篇博客《当析构函数遇到多线程——C++ 中线程安全的目标回调》详细评论了这个问题 
http://blog.csdn.net/Solstice/archive/2010/01/22/5238671.aspx

除了使命行列,还能够用 blocking_queue T  完成数据的顾客-生产者行列,即 T 的是数据类型而非函数目标,queue 的顾客(s)从中拿到数据进行处理。这样做比 task queue 愈加 specific 一些。

 

blocking_queue T  是多线程编程的利器,它的完成可参照 Java 5 util.concurrent 里的 (Array|Linked)BlockingQueue,一般 C++ 能够用 deque 来做底层的容器。Java 5 里的代码可读性很高,代码的根本结构和教科书共同(1 个 mutex,2 个 condition variables),健壮性要高得多。假如不想自己完成,用现成的库更好。(我没有用过免费的库,这儿就不乱引荐了,有爱好的同学能够试试 Intel Threading Building Blocks 里的 concurrent_queue T 。)

 

概括

总结起来,我引荐的多线程服务端编程形式为:event loop per thread + thread pool。

l event loop 用作 non-blocking IO 和守时器。

l thread pool 用来做核算,详细能够是使命行列或顾客-生产者行列。

以这种方法写服务器程序,需求一个优质的依据 Reactor 形式的网络库来支撑,我只用过 in-house 的产品,无从比较并引荐市面上常见的 C++ 网络库,抱愧。

程序里详细用几个 loop、线程池的巨细等参数需求依据运用来设定,根本的准则是“阻抗匹配”,使得 CPU 和 IO 都能高效地运作,详细的考虑点容我今后再谈。

 

这儿没有谈线程的退出,留下下一篇 blog“多线程编程反形式”讨论。

 

此外,程序里或许还有单个履行特殊使命的线程,比方 logging,这对运用程序来说根本是不行见的,可是在分配资源(CPU 和 IO)的时分要算进去,防止高估了体系的容量。

 

4 进程间通讯与线程间通讯

Linux 下进程间通讯 (IPC) 的方法不计其数,光 UNPv2 列出的就有:pipe、FIFO、POSIX 音讯行列、同享内存、信号 (signals) 等等,更不必说 Sockets 了。同步原语 (synchronization primitives) 也许多,互斥器 (mutex)、条件变量 (condition variable)、读写锁 (reader-writer lock)、文件锁 (Record locking)、信号量 (Semaphore) 等等。

怎样挑选呢?依据我的个人经历,贵精不贵多,仔细挑选三四样东西就能彻底满意我的工作需求,而且每样我都能用得很熟,,不简略犯错。

 

5 进程间通讯

进程间通讯我首选 Sockets(首要指 TCP,我没有用过 UDP,也不考虑  Unix domain 协议),其最大的长处在于:能够跨主机,具有伸缩性。横竖都是多进程了,假如一台机器处理才干不行,很天然地就能用多台机器来处理。把进程涣散到同一局域网的多台机器上,程序改改 host:port 装备就能持续用。相反,前面列出的其他 IPC 都不能跨机器(比方同享内存功率最高,但再怎样着也不能高效地同享两台机器的内存),约束了 scalability。

在编程上,TCP sockets 和 pipe 都是一个文件描述符,用来收发字节约,都能够 read/write/fcntl/select/poll 等。不同的是,TCP 是双向的,pipe 是单向的 (Linux),进程间双向通讯还得开两个文件描述符,不便利;而且进程要有父子关系才干用 pipe,这些都约束了 pipe 的运用。在收发字节约这一通讯模型下,没有比 sockets/TCP 更天然的 IPC 了。当然,pipe 也有一个经典运用场景,那就是写 Reactor/Selector 时用来异步唤醒 select (或等价的 poll/epoll) 调用(Sun JVM 在 Linux 就是这么做的)。

 

TCP port 是由一个进程独占,且操作体系会自动收回(listening port 和已树立衔接的 TCP socket 都是文件描述符,在进程结束时操作体系会封闭悉数文件描述符)。这说明,即便程序意外退出,也不会给体系留下废物,程序重启之后能比较简略地康复,而不需求重启操作体系(用跨进程的 mutex 就有这个危险)。还有一个长处,已然 port 是独占的,那么能够防止程序重复发动(后边那个进程抢不到 port,天然就无法工作了),形成意料之外的成果。

 

两个进程经过 TCP 通讯,假如一个溃散了,操作体系会封闭衔接,这样另一个进程简直马上就能感知,能够快速 failover。当然,运用层的心跳也是必不行少的,我今后在讲服务端的日期与时刻处理的时分还会谈到心跳协议的规划。

 

与其他 IPC 比较,TCP 协议的一个天然长处是“可记载可重现”,tcpdump/Wireshark 是处理两个进程间协议/状况争端的好帮手。

 

其他,假如网络库带“衔接重试”功用的话,咱们能够不要求体系里的进程以特定的次序发动,任何一个进程都能独自重启,这对开发可靠的分布式体系意义严重。

 

运用 TCP 这种字节约 (byte stream) 方法通讯,会有 marshal/unmarshal 的开支,这要求咱们选用适宜的音讯格局,精确地说是 wire format。这将是我下一篇 blog 的主题,现在我引荐 Google Protocol Buffers。

 

有人或许会说,详细问题详细剖析,假如两个进程在同一台机器,就用同享内存,不然就用 TCP,比方 MS SQL Server 就一同支撑这两种通讯方法。我问,是否值得为那么一点功用提高而让代码的杂乱度大大添加呢?TCP 是字节约协议,只能次序读取,有写缓冲;同享内存是音讯协议,a 进程填好一块内存让 b 进程来读,根本是“停等”方法。要把这两种方法揉到一个程序里,需求建一个笼统层,封装两种 IPC。这会带来不透明性,而且添加测验的杂乱度,而且假如通讯的某一方溃散,状况 reconcile 也会比 sockets 费事。为我所不取。再说了,你舍得让几万块买来的 SQL Server 和你的程序同享机器资源吗?产品里的数据库服务器往往是独立的高装备服务器,一般不会一同运转其他占资源的程序。

 

TCP 自身是个数据流协议,除了直接运用它来通讯,还能够在此之上构建 RPC/REST/SOAP 之类的上层通讯协议,这超越了本文的规模。其他,除了点对点的通讯之外,运用级的播送协议也是十分有用的,能够便利地构建可观可控的分布式体系。

 

本文不详细讲 Reactor 方法下的网络编程,其实这儿边有许多值得留意的当地,比方带 back off 的 retry connecting,用优先行列来安排 timer 等等,留作今后剖析吧。

 

6 线程间同步

线程同步的四项准则,按重要性摆放:

1. 首要准则是尽量最低极限地同享目标,削减需求同步的场合。一个目标能不露出给其他线程就不要露出;假如要露出,优先考虑 immutable 目标;真实不行才露出可修正的目标,并用同步方法来充沛维护它。

2. 其次是运用高档的并发编程构件,如 TaskQueue、Producer-Consumer Queue、CountDownLatch 等等;

3. 最终不得已有必要运用底层同步原语 (primitives) 时,只用非递归的互斥器和条件变量,偶然用一用读写锁;

4. 不自己编写 lock-free 代码,不去凭空猜测“哪种做法功用会更好”,比方 spin lock vs. mutex。

前面两条很简略了解,这儿着重讲一下第 3 条:底层同步原语的运用。

互斥器 (mutex)

互斥器 (mutex) 恐怕是运用得最多的同步原语,粗略地说,它维护了临界区,一个时刻最多只能有一个线程在临界区内活动。(请留意,我谈的是 pthreads 里的 mutex,不是 Windows 里的重量级跨进程 Mutex。)独自运用 mutex 时,咱们首要为了维护同享数据。我个人的准则是:

l 用 RAII 方法封装 mutex 的创立、毁掉、加锁、解锁这四个操作。

l 只用非递归的 mutex(即不行重入的 mutex)。

l 不手艺调用 lock() 和 unlock() 函数,悉数交给栈上的 Guard 目标的结构和析构函数担任,Guard 目标的生命期正好等于临界区(剖析目标在什么时剖析构是 C++ 程序员的根本功)。这样咱们确保在同一个函数里加锁和解锁,防止在 foo() 里加锁,然后跑到 bar() 里解锁。

l 在每次结构 Guard 目标的时分,考虑一路上(调用栈上)现已持有的锁,防止因加锁次序不同而导致死锁 (deadlock)。由于 Guard 目标是栈上目标,看函数调用栈就能剖析用锁的状况,十分便当。

 

非有必要准则有:

l 不运用跨进程的 mutex,进程间通讯只用 TCP sockets。

l 加锁解锁在同一个线程,线程 a 不能去 unlock 线程 b 现已锁住的 mutex。(RAII 自动确保)

l 别忘了解锁。(RAII 自动确保)

l 不重复解锁。(RAII 自动确保)

l 必要的时分能够考虑用 PTHREAD_MUTEX_ERRORCHECK 来排错

 

用 RAII 封装这几个操作是通行的做法,这简直是 C++ 的规范实践,后边我会给出详细的代码示例,信任咱们都现已写过或用过相似的代码了。Java 里的 synchronized 句子和 C# 的 using 句子也有相似的效果,即确保锁的收效期间等于一个效果域,不会因反常而忘掉解锁。

Mutex 恐怕是最简略的同步原语,安照上面的几条准则,简直不或许用错。我自己从来没有违背过这些准则,编码时呈现问题都很快能招到并修正。

 

跑题:非递归的 mutex

谈谈我坚持运用非递归的互斥器的个人主意。

Mutex 分为递归 (recursive) 和非递归(non-recursive)两种,这是 POSIX 的叫法,其他的姓名是可重入 (Reentrant) 与非可重入。这两种 mutex 作为线程间 (inter-thread) 的同步东西时没有差异,它们的专一差异在于:同一个线程能够重复对 recursive mutex 加锁,可是不能重复对 non-recursive mutex 加锁。

首选非递归 mutex,肯定不是为了功用,而是为了表现规划目的。non-recursive 和 recursive 的功用不同其实不大,由于少用一个计数器,前者略快一点点罢了。在同一个线程里屡次对 non-recursive mutex 加锁会马上导致死锁,我以为这是它的长处,能协助咱们考虑代码对锁的祈求,而且及早(在编码阶段)发现问题。

毫无疑问 recursive mutex 运用起来要便利一些,由于不必考虑一个线程会自己把自己给锁死了,我猜这也是 Java 和 Windows 默许供给 recursive mutex 的原因。(Java 言语自带的 intrinsic lock 是可重入的,它的 concurrent 库里供给 ReentrantLock,Windows 的 CRITICAL_SECTION 也是可重入的。好像它们都不供给轻量级的 non-recursive mutex。)

 

正由于它便利,recursive mutex 或许会躲藏代码里的一些问题。典型状况是你以为拿到一个锁就能修正目标了,没想到外层代码现已拿到了锁,正在修正(或读取)同一个目标呢。详细的比方:

 

std::vector Foo  foos;

MutexLock mutex;

 

void post(const Foo  f)

{

  MutexLockGuard lock(mutex);

  foos.push_back(f);

}

 

void traverse()

{

  MutexLockGuard lock(mutex);

  for (auto it = foos.begin(); it != foos.end(); ++it) { // 用了 0x 新写法

    it- doit();

  }

}

post() 加锁,然后修正 foos 目标; traverse() 加锁,然后遍历 foos 数组。将来有一天,Foo::doit() 直接调用了 post() (这在逻辑上是过错的),那么会很有戏剧性的:

1. Mutex 对错递归的,所以死锁了。

2. Mutex 是递归的,由于 push_back 或许(但不总是)导致 vector 迭代器失效,程序偶然会 crash。

这时分就能表现 non-recursive 的优越性:把程序的逻辑过错露出出来。死锁比较简略 debug,把各个线程的调用栈打出来((gdb) thread apply all bt),只需每个函数不是特别长,很简略看出来是怎样死的。(另一方面支撑了函数不要写过长。)或许能够用 PTHREAD_MUTEX_ERRORCHECK 一会儿就能找到过错(条件是 MutexLock 带 debug 选项。)

 

程序横竖要死,不如死得有意义一点,让验尸官的日子好过些。

 

假如一个函数既或许在已加锁的状况下调用,又或许在未加锁的状况下调用,那么就拆成两个函数:

 

1. 跟原本的函数同名,函数加锁,转而调用第 2 个函数。

2. 给函数名加上后缀 WithLockHold,不加锁,把原本的函数体搬过来。

就像这样:

 

void post(const Foo  f)

{

  MutexLockGuard lock(mutex);

  postWithLockHold(f);  // 不必忧虑开支,编译器会自动内联的

}

 

 

// 引进这个函数是为了表现代码作者的目的,尽管 push_back 一般能够手动内联

void postWithLockHold(const Foo  f)

{

  foos.push_back(f);

}

 

这有或许呈现两个问题(感谢水木网友 ilovecpp 提出):a) 误用了加锁版别,死锁了。b) 误用了不加锁版别,数据损坏了。

关于 a),仿制前面的方法能比较简略地排错。关于 b),假如 pthreads 供给 isLocked() 就好办,能够写成:

void postWithLockHold(const Foo  f)

{

  assert(mutex.isLocked());  // 现在仅仅一个希望

  // ...

}

 

其他,WithLockHold 这个显眼的后缀也让程序中的误用简略露出出来。

C++ 没有 annotation,不能像 Java 那样给 method 或 field 标上 @GuardedBy 注解,需求程序员自己当心介意。尽管这儿的方法不能一了百了地处理悉数多线程过错,但能帮上一点是一点了。

 

我还没有遇到过需求运用 recursive mutex 的状况,我想将来遇到了都能够凭借 wrapper 改用 non-recursive mutex,代码只会更明晰。

 

 

= 回到正题 =

 

本文这儿只谈了 mutex 自身的正确运用,在 C++ 里多线程编程还会遇到其他许多 race condition,请参阅拙作《当析构函数遇到多线程——C++ 中线程安全的目标回调》
http://blog.csdn.net/Solstice/archive/2010/01/22/5238671.aspx 。请留意这儿的 class 命名与那篇文章有所不同。我现在以为 MutexLock 和 MutexLockGuard 是更好的称号。

 

功用注脚:Linux 的 pthreads mutex 选用 futex 完成,不必每次加锁解锁都堕入体系调用,功率不错。Windows 的 CRITICAL_SECTION 也是相似。

条件变量

条件变量 (condition variable) 望文生义是一个或多个线程等候某个布尔表达式为真,即等候其他线程“唤醒”它。条件变量的学名叫管程 (monitor)。Java Object 内置的 wait(), notify(), notifyAll() 便是条件变量(它们以简略用错著称)。条件变量只需一种正确运用的方法,关于 wait() 端:

1. 有必要与 mutex 一同运用,该布尔表达式的读写需受此 mutex 维护

2. 在 mutex 已上锁的时分才干调用 wait()

3. 把判别布尔条件和 wait() 放到 while 循环中

 

写成代码是:

MutexLock mutex;

Condition cond(mutex);

std::deque int  queue;

 

int dequeue()

{

  MutexLockGuard lock(mutex);

  while (queue.empty()) {  // 有必要用循环;有必要在判别之后再 wait()

    cond.wait(); // 这一步会原子地 unlock mutex 并进入 blocking,不会与 enqueue 死锁

  }

  assert(!queue.empty());

  int top = queue.front();

  queue.pop_front();

  return top;

}

 

关于 signal/broadcast 端:

1. 不一定要在 mutex 已上锁的状况下调用 signal (理论上)

2. 在 signal 之前一般要修正布尔表达式

3. 修正布尔表达式一般要用 mutex 维护(至少用作 full memory barrier)

写成代码是:

void enqueue(int x)

{

  MutexLockGuard lock(mutex);

  queue.push_back(x);

  cond.notify();

}

上面的 dequeue/enqueue 实际上完成了一个简略的 unbounded BlockingQueue。

 

 

条件变量是十分底层的同步原语,很少直接运用,一般都是用它来完成高层的同步方法,如 BlockingQueue 或 CountDownLatch。

读写锁与其他

读写锁 (Reader-Writer lock),读写锁是个优异的笼统,它清晰差异了 read 和 write 两种行为。需求留意的是,reader lock 是可重入的,writer lock 是不行重入(包括不行提高 reader lock)的。这正是我说它“优异”的首要原因。

遇到并发读写,假如条件适宜,我会用《借 shared_ptr 完成线程安全的 copy-on-write》http://blog.csdn.net/Solstice/archive/2008/11/22/3351751.aspx 介绍的方法,而不必读写锁。当然这不是肯定的。

 

 

信号量 (Semaphore),我没有遇到过需求运用信号量的状况,无从谈及个人经历。

说一句犯上作乱的话,假如程序里需求处理如“哲学家就餐”之类的杂乱 IPC 问题,我以为应该首要查询几个规划,为什么线程之间会有如此杂乱的资源争抢(一个线程要一同抢到两个资源,一个资源能够被两个线程抢夺)?能不能把“想吃饭”这个工作专门交给一个为各位哲学家分配餐具的线程来做,然后每个哲学家等在一个简略的 condition variable 上,到时刻了有人告诉他去吃饭?从哲学上说,教科书上的处理计划是平权,每个哲学家有自己的线程,自己去拿筷子;我甘愿用集权的方法,用一个线程专门管餐具的分配,让其他哲学家线程拿个号等在食堂门口好了。这样不丢失多少功率,却让程序简略许多。尽管 Windows 的 WaitForMultipleObjects 让这个问题 trivial 化,在 Linux 下正确模仿 WaitForMultipleObjects 不是一般程序员该干的。

 

封装 MutexLock、MutexLockGuard 和 Condition

本节把前面用到的 MutexLock、MutexLockGuard、Condition classes 的代码列出来,前面两个 classes 没多大难度,后边那个有点意思。

MutexLock 封装临界区(Critical secion),这是一个简略的资源类,用 RAII 方法 [CCS:13]封装互斥器的创立与毁掉。临界区在 Windows 上是 CRITICAL_SECTION,是可重入的;在 Linux 下是 pthread_mutex_t,默许是不行重入的。MutexLock 一般是其他 class 的数据成员。

 

MutexLockGuard 封装临界区的进入和退出,即加锁和解锁。MutexLockGuard 一般是个栈上目标,它的效果域刚好等于临界区域。

 

这两个 classes 应该能在纸上默写出来,没有太多需求解说的:

 

#include  pthread.h

#include  boost/noncopyable.hpp

 

class MutexLock : boost::noncopyable

{

 public:

  MutexLock()  // 为了节约版面,单行函数都没有正确缩进

  { pthread_mutex_init( mutex_, NULL); }

 

  ~MutexLock()

  { pthread_mutex_destroy( mutex_); }

 

  void lock()  // 程序一般不自动调用

  { pthread_mutex_lock( mutex_); }

 

  void unlock()  // 程序一般不自动调用

  { pthread_mutex_unlock( mutex_); }

 

  pthread_mutex_t* getPthreadMutex()  // 仅供 Condition 调用,禁止自己调用

  { return  mutex_; }

 

 private:

  pthread_mutex_t mutex_;

};

 

class MutexLockGuard : boost::noncopyable

{

 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")

 

留意代码的最终一行界说了一个宏,这个宏的效果是防止程序里呈现如下过错:

void doit()

{

  MutexLockGuard(mutex);  // 没有变量名,发作一个暂时目标又马上毁掉了,没有锁住临界区

  // 正确写法是 MutexLockGuard lock(mutex);

 

  // 临界区

}

 

这儿 MutexLock 没有供给 trylock() 函数,由于我没有用过它,我想不出什么时分程序需求“试着去锁一锁”,或许我写过的代码太简略了。

我见过有人把 MutexLockGuard 写成 template,我没有这么做是由于它的模板类型参数只需 MutexLock 一种或许,没有必要随意添加灵敏性,所以我人肉把模板具现化 (instantiate) 了。此外一种更急进的写法是,把 lock/unlock 放到 private 区,然后把 Guard 设为 MutexLock 的 friend,我以为在注释里奉告程序员即可,其他 check-in 之前的 code review 也很简略发现误用的状况 (grep getPthreadMutex)。

 

这段代码没有到达工业强度:a) Mutex 创立为 PTHREAD_MUTEX_DEFAULT 类型,而不是咱们料想的 PTHREAD_MUTEX_NORMAL 类型(实际上这二者很或许是同等的),严厉的做法是用 mutexattr 来显现指定 mutex 的类型。b) 没有查看返回值。这儿不能用 assert 查看返回值,由于 assert 在 release build 里是空句子。咱们查看返回值的意义在于防止 ENOMEM 之类的资源缺乏状况,这一般只或许在负载很重的产品程序中呈现。一旦呈现这种过错,程序有必要马上整理现场并自动退出,不然会不可思议地溃散,给过后查询形成困难。这儿咱们需求 non-debug 的 assert,或许 google-glog 的 CHECK() 是个不错的思路。

 

以上两点改善留作操练。

 

 

 

Condition class 的完成有点意思。

Pthreads condition variable 答应在 wait() 的时分指定 mutex,可是我想不出什么理由一个 condition variable 会和不同的 mutex 合作运用。Java 的 intrinsic condition 和 Conditon class 都不支撑这么做,因而我觉得能够抛弃这一灵敏性,老厚道实一对一好了。相反 boost::thread 的 condition_varianle 是在 wait 的时分指定 mutex,请观赏其同步原语的杂乱规划:

 

l Concept 有四种 Lockable, TimedLockable, SharedLockable, UpgradeLockable.

l Lock 有五六种: lock_guard, unique_lock, shared_lock, upgrade_lock, upgrade_to_unique_lock, scoped_try_lock.

l Mutex 有七种:mutex, try_mutex, timed_mutex, recursive_mutex, recursive_try_mutex, recursive_timed_mutex, shared_mutex.

恕我弛禁,见到 boost::thread 这样如 Rube Goldberg Machine 相同“灵敏”的库我只得三揖绕道而行。这些 class 姓名也很无厘头,为什么不老厚道有用 reader_writer_lock 这样的浅显姓名呢?非得添加精神负担,自己创造新姓名。我不愿为这样的灵敏性付出代价,甘愿自己做几个简简略单的一看就了解的 classes 来用,这种简略的几行代码的轮子造造也不妨。供给灵敏性固然是本事,但是在不需求灵敏性的当地把代码写死,更需求大智慧。

 

下面这个 Condition 简略地封装了 pthread cond var,用起来也简略,见本节前面的比方。这儿我用 notify/notifyAll 作为函数名,由于 signal 有其他意义,C++ 里的 signal/slot,C 里的 signal handler 等等。就别 overload 这个术语了。

 

 

class Condition : boost::noncopyable

{

 public:

  Condition(MutexLock  mutex) : mutex_(mutex)

  { pthread_cond_init( pcond_, NULL); }

 

  ~Condition()

  { pthread_cond_destroy( 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_;

};

 

 

假如一个 class 要包括 MutexLock 和 Condition,请留意它们的声明次序和初始化次序,mutex_ 应先于 condition_ 结构,并作为后者的结构参数:

class CountDownLatch

 

{

 public:

  CountDownLatch(int count)

   : count_(count),

     mutex_(),

     condition_(mutex_)

  { }

 

 private:

  int count_;

  MutexLock mutex_;  // 次序很重要

  Condition condition_;

};

 

 

请答应我再次着重,尽管本节花了许多篇幅介绍怎样正确运用 mutex 和 condition variable,但并不代表我鼓舞处处运用它们。这两者都是十分底层的同步原语,首要用来完成更高档的并发编程东西,一个多线程程序里假如许多运用 mutex 和 condition variable 来同步,根本跟用铅笔刀锯大树(孟岩语)没啥差异。

线程安全的 Singleton 完成

研讨 Signleton 的线程安全完成的前史你会发现许多有意思的工作,一度人们以为 Double checked locking 是王道,统筹了功率与正确性。后来有神牛指出由于乱序履行的影响,DCL 是靠不住的。(这个又让我想起了 SQL 注入,十年前用字符串拼接出 SQL 句子是 Web 开发的通行做法,直到有一天有人运用这个缝隙越权获得并修正网站数据,人们才幡然醒悟,赶忙修补。)Java 开发者还算走运,能够凭借内部静态类的装载来完成。C++ 就比较惨,要么次次锁,要么 eager initialize、或许动用 memory barrier 这样的大杀器( http://www.aristeia.com/Papers/DDJ_Jul_Aug_2004_revised.pdf )。接下来 Java 5 修订了内存模型,并增强了 volatile 的语义,这下 DCL (with volatile) 又是安全的了。但是 C++ 的内存模型还在修订中,C++ 的 volatile 现在还不能(将来也难说)确保 DCL 的正确性(只在 VS2005+ 上有用)。

 

其实没那么费事,在实践中用 pthread once 就行:

#include  pthread.h

 

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_ = NULL;

 

上面这个 Singleton 没有任何花哨的技巧,用 pthread_once_t 来确保 lazy-initialization 的线程安全。运用方法也很简略:

 

Foo  foo = Singleton Foo ::instance();

 

当然,这个 Singleton 没有考虑目标的毁掉,在服务器程序里,这不是一个问题,由于当程序退出的时分天然就开释悉数资源了(条件是程序里不运用不能由操作体系自动封闭的资源,比方跨进程的 Mutex)。其他,这个 Singleton 只能调用默许结构函数,假如用户想要指定 T 的结构方法,咱们能够用模板特化 (template specialization) 技能来供给一个定制点,这需求引进另一层直接。

概括

l 进程间通讯首选 TCP sockets

l 线程同步的四项准则

l 运用互斥器的条件变量的惯用方法 (idiom),关键是 RAII

用好这几样东西,根本上能敷衍多线程服务端开发的各种场合,仅仅或许有人会觉得功用没有发挥到极致。我以为,先把程序写正确了,再考虑功用优化,这在多线程下任然建立。让一个正确的程序变快,远比“让一个快的程序变正确”简略得多。

 

7 总结

在现代的多核核算布景下,线程是不行防止的。多线程编程是一项重要的个人技能,不能由于它难就天性地排挤,现在的软件开发比起 10 年 20 年前现已难了不知道多少倍。把握多线程编程,才干更沉着地挑选用仍是不必多线程,由于你能预估多线程完成的难度与收益,在一开始做出正确的挑选。要知道把一个单线程程序改成多线程的,往往比重头完成一个多线程的程序更难。

把握同步原语和它们的适用场合时多线程编程的根本功。以我的经历,娴熟运用文中说到的同步原语,就能比较简略地编写线程安全的程序。本文没有考虑 signal 对多线程编程的影响,Unix 的 signal 在多线程下的行为比较杂乱,一般要靠底层的网络库 (如 Reactor) 加以屏蔽,防止搅扰上层运用程序的开发。

 

通篇来看,“功率”并不是我的首要考虑点,a) TCP 不是功率最高的 IPC,b) 我发起正确加锁而不是自己编写 lock-free 算法(运用原子操作在外)。在程序的杂乱度和功用之前获得平衡,并经考虑未来两三年扩容的或许(不管是 CPU 变快、核数变多,仍是机器数量添加,网络晋级)。下一篇“多线程编程的反形式”会查询伸缩性方面的常见过错,我以为在分布式体系中,伸缩性 (scalability) 比单机的功用优化更值得投入精力。

 

这篇文章记载了我现在对多线程编程的了解,用文中介绍的方法,我能处理自己面对的悉数多线程编程使命。假如文章的观念与您不合,比方您运用了我没有引荐运用的技能或方法(同享内存、信号量等等),只需您理由充沛,但行不妨。

 

这篇文章原本还有两节“多线程编程的反形式”与“多线程的运用场景”,考虑到字数现已超越一万了,且听下回分解吧 :-)

 

后文预览:Sleep 反形式

我以为 sleep 只能呈现在测验代码中,比方写单元测验的时分。(触及时刻的单元测验不那么好写,短的如一两秒钟能够用 sleep,长的如一小时一天得想其他方法,比方把算法提出来并把时刻注入进去。)产品代码中线程的等候可分为两种:一种是无所事事的时分(要么等在 select/poll/epoll 上。要么等在 condition variable 上,等候 BlockingQueue /CountDownLatch 亦可归入此类),一种是等着进入临界区(等在 mutex 上)以便持续处理。在程序的正常履行中,假如需求等候一段时刻,应该往 event loop 里注册一个 timer,然后在 timer 的回调函数里接着干活,由于线程是个宝贵的同享资源,不能容易糟蹋。假如多线程的安全性和功率要靠代码自动调用 sleep 来确保,这是规划出了问题。等候一个工作发作,正确的做法是用 select 或 condition variable 或(更理想地)高层同步东西。当然,在 GUI 编程中会有自动让出 CPU 的做法,比方调用 sleep(0) 来完成 yield。

版权声明
本文来源于网络,版权归原作者所有,其内容与观点不代表凯发娱乐立场。转载文章仅为传播更有价值的信息,如采编人员采编有误或者版权原因,请与我们联系,我们核实后立即修改或删除。

猜您喜欢的文章