并发编程
如果逻辑控制流在实际上重叠, 那么它们就是并发的, 这种常见的现象称为并发, 出现在计算机系统的许多不同层面上
应用级并发在其他情况下也是很有用的:
访问慢速 I/O 设备
与人交互
通过推迟工作以降低延迟
服务多个网络客户端
在多核机器上进行并行计算
使用应用级并发的应用程序称为并发程序现代操作系统提供了三种基本的构造并发程序的方法:
进程用这种方法, 每个逻辑控制流都是一个进程, 由内核来调度和维护因为进程有独立的虚拟地址空间, 想要和其他流通信, 控制流必须使用某种显式的进程间通信机制
I/O 多路复用在这种形式的并发编程中, 应用程序在一个进程的上下文中显式地调度它们自己的逻辑流逻辑流被模型化为状态机, 数据到达文件描述符后, 主程序显式地从一个状态转换到另一个状态因为程序是一个单进程, 所以所有的流都共享同一地址空间
线程线程是运行在一个单一进程上下文中的逻辑流, 由内核进行调度你可以把线程看成是其他两种方式的混合体, 像进程流一样由内核进行调度, 而像 I/O 多路复用流一样共享同一虚拟地址空间
构造并发编程最简单的方法就是用进程, 使用那些大家都很熟悉的函数, 像 forkexec 和 waitpid
步骤:
1)服务器监听一个监听描述符上的连接请求
2)服务器接受了客户端 1 的连接请求, 并返回一个已连接描述符
3)在接受了连接请求之后, 服务器派生一个子进程, 这个子进程获得服务器描述符表的完整拷贝子进程关闭它的拷贝中的监听描述符 3, 而父进程关闭它的已连接描述符 4 的拷贝, 因为不再需要这些描述符了
4)子进程正忙于为客户端提供服务, 父进程继续监听新的请求
注意: 子进程关闭监听描述符和父进程关闭已连接描述符是很重要的, 因为父子进程共用同一文件表, 文件表中的引用计数会增加, 只有当引用计数减为 0 时, 文件描述符才会真正关闭所以, 如果父子进程不关闭不用的描述符, 将永远不会释放这些描述符, 最终将引起存储器泄漏而最终消耗尽可以的存储器, 是系统崩溃
使用进程并发编程要注意的问题:
1)首先, 通常服务器会运行很长的时间, 所以我们必须要包括一个 SIGCHLD 处理程序, 来回收僵死子进程的资源因为当 SIGCHLD 处理程序执行时, SIGCHLD 信号时阻塞的, 而 Unix 信号时不排队的, 所以 SIGCHLD 处理程序必须准备好回收多个僵死子进程的资源
2)其次, 子进程必须关闭它们各自的 connfd 拷贝就像我们已经提到过的, 这对父进程而言尤为重要, 它必须关闭它的已连接描述符, 以避免存储器泄漏
3)最后, 因为套接字的文件表表项中的引用计数, 直到父子进程的 connfd 都关闭了, 到客户端的连接才会终止
缺点:
对于父子进程间共享状态信息, 进程有一个非常清晰的模型: 共享文件表, 但是不共享用户地址空间进程有独立的地址空间既是优点也是缺点这样一来, 一个进程不可能不小心覆盖另一个进程的虚拟存储器, 这就消除了许多令人迷惑的错误这是一个明显的优点
另一方面, 独立的地址空间使得进程共享状态信息变得更加困难为了共享信息, 它们必须使用显式的 IPC(进程间通信)机制基于进程的设计的另一个缺点是, 它们往往比较慢, 因为进程控制和 IPC 的开销很高
基于 I/O 多路复用的并发编程
1 面对困境服务器必须响应两个互相独立的 I/O 事件: 1)网络客户端发起的连接请求 2)用户在键盘上键入的命令 , 解决的办法是 I/O 多路复用技术基本思想是, 使用 select 函数, 要求内核挂起进程, 只有在一个或多个 I/O 事件发生后, 才将控制返回给应用程序
可以使用 selectpoll 和 epoll 来实现 I/O 复用
I/O 多路复用技术的优劣:
1)使用事件驱动编程, 这样比基于进程的设计给了程序更多的对程序行为的控制
2)一个基于 I/O 多路复用的事件驱动服务器是运行在单一进程上下文中的, 因此每个逻辑流都访问该进程的全部地址空间这使得在流之间共享数据变得很容易一个与作为单进程运行相关的优点是, 你可以利用熟悉的调试工具, 例如 GDB 来调试你的并发服务器, 就像对顺序程序那样最后, 事件驱动设计常常比基于进程的设计要高效很多, 因为它们不需要进程上下文切换来调度新的流
缺点:
事件驱动设计的一个明星的缺点就是编码复杂我们的事件驱动的并发服务器需要比基于进程的多三倍不幸的是, 随着并发粒度的减小, 复杂性还会上升这里的粒度是指每个逻辑流每个时间片执行的指令数量
基于事件的设计的另一重大的缺点是它们不能充分利用多核处理器
基于线程的并发编程
在使用进程并发编程中, 我们为每个流使用了单独的进程内核会自动调用每个进程每个进程有它自己的私有地址空间, 这使得流共享数据很困难在使用 I/O 多路复用的并发编程中, 我们创建了自己的逻辑流, 并利用 I/O 多路复用来显式地调度流因为只有一个进程, 所有的流共享整个地址空间而基于线程的方法, 是这两种方法的混合
线程就是运行在进程上下文的逻辑流线程由内核自动调度每个线程都有它自己的线程上下文, 包括一个唯一的整数线程 ID 栈栈指针程序计数器通用目的寄存器和条件码所有的运行在一个进程里的线程共享该进程的整个虚拟地址空间
基于线程的逻辑流结合了基于线程和基于 I/O 多路复用的流的特性同进程一样, 线程由内核自动调度, 并且内核通过一个整数 ID 来标识线程同基于 I/O 多路复用的流一样, 多个线程运行在单一进程的上下文中, 因此共享这个线程虚拟地址空间的整个内容, 包括它的代码数据堆共享库和打开的文件
线程执行模型
多线程的执行模型在某些方面和多进程的执行模型是相似的每个进程开始生命周期时都是单一线程, 这个线程是主线程在某一时刻, 主线程创建一个对等线程, 从这个时间点开始, 两个线程就并发地运行最后, 因为主线程执行一个慢速系统调用, 例如 read 和 sleep, 或者因为它被系统的间隔计时器中断, 控制就会通过上下文切换到对等线程对等线程会执行一段时间, 然后控制传递回主线程, 依次类推
在一些重要的方法, 线程执行时不同于进程的因为一个线程的上下文要比一个进程的上下文小很多, 线程的上下文切换要比进程的上下文切换快得多另一个不同就是线程不像进程那样, 不是按照严格的父子层次来组织的和一个进程相关的线程组成一个对等 (线程) 池, 独立于其他线程创建的线程主线程和其他线程的区别仅在于它总是进程中第一个运行的线程对等 (线程) 池概念的主要影响是, 一个线程可以杀死它的任何对等线程, 或者等待它的任意对等线程终止另外, 每个对等线程都能读写相同的共享数据
Posix 线程
创建线程
线程通过调用 pthread_create 函数来创建其他线程:
pthread_create 函数创建一个新的线程, 并带着一个输入变量 arg, 在新线程的上下文中运行线程例程 f 能用 attr 参数来改变新创建线程的默认属性
当 pthread_create 返回时, 参数 tid 包含新创建线程的 ID 新线程可以通过调用 pthread_self 函数来获得它自己的线程 ID
终止线程
一个线程是以下列方式之一来终止的:
当顶层的线程例程返回时, 线程会隐式地终止
通过调用 pthread_exit 函数, 线程会显式地终止如果主线程调用 pthread_exit, 它会等待所有其他对等线程终止, 然后再终止主线程和这个进程, 返回值为 thread_return
某个对等线程调用 exit 函数, 则函数终止进程和所有与该进程相关的线程;
另一个对等线程调用以当前 ID 为参数的函数 ptherad_cancel 来终止当前线程
回收已终止线程的资源
pthread_join 函数会终止, 直到线程 tid 终止, 将线程例程返回的 (void*) 指针赋值为 thread_return 指向的位置, 然后回收已终止线程占用的所有存储器资源和 wait 不同, 该函数只能回收指定 id 的线程, 不能回收任意线程
分离线程
在任何一个时间点上, 线程是可结合的或者是分离的一个可结合的线程能够被其他线程收回其资源和杀死在被其他线程回收之前, 它的存储器资源 (例如栈) 式没有被释放的相反, 一个分离的线程是不能被其他线程回收和杀死的它的存储器资源在它终止时由系统自动释放
默认情况下, 线程被创建成可结合的为了避免存储器泄漏, 每个可结合线程都应该要么被其他线程显式地收回, 要么通过调用 pthread_detach 函数被分离
pthread_detach 函数分离可结合线程 tid 线程能够通过以 pthread_self()为参数的 pthread_detach 调用来分离它们自己
初始化线程: 该函数用来初始化多个线程共享的全局变量
多线程程序中的共享变量
从一个程序员的角度来看, 线程很有吸引力的一个方面就是多个线程很容易共享相同的程序变量然而, 这种共享也是很棘手的为了编写正确的线程化程序, 我们必须对所谓的共享以及它是如何工作的有很清楚的了解
为了理解变量是否是共享的, 有一些基本的问题要解答: 1)线程的基础存储器模型是什么? 2)根据这个模型, 变量实例是如何映射到存储器的? 3)最后, 有多少线程引用这些实例? 一个变量是共享的, 当且仅当多个线程引用这个变量的某个实例
线程存储器模型:
一组并发线程运行在一个进程的上下文中 每个线程都有它自己独自的线程上下文, 包括线程 ID 栈栈指针程序计数器条件码和通用目的寄存器值每个线程和其他线程一起共享进程上下文的剩余部分这包括整个用户虚拟地址空间, 它是由只读文本 (代码) 读 / 写数据堆以及所有的共享库代码和数据区域组成的线程也共享同样的打开文件的集合
从实际操作的角度来说, 让一个线程去读或写另一个线程的寄存器值时不可能的另一方面, 任何线程都可以访问共享虚拟存储器的任意位置如果某个线程修改了存储器的位置, 那么其他每个线程最终都能在它读这个位置时发现这个变化因此, 寄存器是从来不共享的, 而虚拟存储器总是共享的
各自独立的线程栈的存储器模型不是那么整齐清楚的这些栈被保存在虚拟地址空间的栈区域中, 并且通常是被相应的线程独立地访问的我们说通常而不是总是, 是因为不同的线程栈是不对其他线程设防的所以, 如果一个线程以某种方式得到一个指向其他线程栈的指针, 那么它就可以读写这个栈的任何部分
将变量映射到存储器:
线程化的 C 程序中变量根据它们的存储器类型被映射到虚拟存储器:
全局变量全局变量是定义在函数之外的变量在运行时, 虚拟存储器的读 / 写区域只包含每个全局变量的一个实例, 任何线程都可以引用
本地自动变量本地自动变量就是定义在函数内部但是没有 static 属性的变量在运行时, 每个线程的栈都包含它自己的所有本地自动变量的实例即使当多个线程执行同一个线程例程时也是如此
本地静态变量本地静态变量是定义在函数内部并有 static 属性的变量和全局变量一样, 虚拟存储器的读 / 写区域只包含在程序中声明的每个本地静态变量的一个实例
共享变量
我们说一个变量 v 是共享的, 当且仅当它的一个实例被一个以上的线程引用
共享变量的同步与互斥
1)使用信号量同步线程
共享变量引入了同步错误
进度图: 轨迹线示例: 临界区(不安全区):
信号量: 是用信号量解决同步问题, 信号量 s 是具有非负整数值的全局变量, 有两种特殊的操作来处理(P 和 V):
P(s): 如果 s 非零, 那么 P 将 s 减 1, 并且立即返回如果 s 为 0, 那么就挂起这个线程, 直到 s 变为非零;
V(s):V 操作将 s 加 1
使用信号量实现互斥:
利用信号量调度共享资源: 在这种场景中, 一个线程用信号量操作来通知另一个线程, 程序状态中的某个条件已经为真了两个经典应用:
a)生产者消费者问题
要求: 必须保证对缓冲区的访问是互斥的; 还需要调度对缓冲区的访问, 即, 如果缓冲区是满的(没有空的槽位), 那么生产者必须等待直到有一个空的槽位为止, 如果缓冲区是空的(即没有可取的项目), 那么消费者必须等待直到有一个项目变为可用
注释: 5~13 行, 缓冲区初始化, 主要是对缓冲区结构体进行相关操作; 16~19 行, 释放缓冲区存储空间; 22~29 行, 生产(有空槽的话, 在空槽中插入内容);32~4 行, 消费(去除某个槽中的内容, 使该槽为空)
b)读者写者问题
修改对象的线程叫做写者; 只读对象的线程叫做读者写着必须拥有对对象的独占访问, 而读者可以和无限多个其他读者共享对象读者写者问题基本分为两类: 第一类, 读者优先, 要求不要让读者等待, 除非已经把使用对象的权限赋予了一个写者换句话说, 读者不会因为有一个写者等待而等待; 第二类, 写者优先, 要求一定能写者准备好可以写, 它就会尽可能地完成它的写操作同第一类问题不同, 在一个写者后到达的读者必须等待, 即使这个写者也是在等待以下程序给出了第一类读者写者问题的解答:
注释: 信号量 w 控制对访问共享对象的临界区的访问信号量 mutex 保护对共享变量 readcnt 的访问, readcnt 统计当前临界区的读者数量每当一个写者进入临界区, 它就对互斥锁 w 加锁, 每当它离开临界区时, 对 w 解锁, 这就保证了任意时刻临界区最多有一个写者; 另一方面, 只有第一个进入临界区的读者对 w 加锁, 而只有最后一个离开临界区的读者对 w 解锁
综合: 基于预线程的并发服务器之前介绍的基于线程的并发服务器, 需要为每个客户端新建一个新线程, 导致不小的代价一个基于预线程化的服务器通过使用如下图所示的生产者消费者模型来降低这种开销服务器是由一个主线程和一组工作组线程构成的主线程不断地接受来自客户端的连接请求, 并将得到的连接描述符放在一个有限缓冲区中每一个工作组线程反复地从共享缓冲区中取出描述符, 为客户端服务, 然后等待下一个描述符
程序示例如下图:
注释: 26~27 行, 产生工作组线程; 29~32 行, 接受客户端的连接请求, 并把这些描述符放到缓冲区; 35~43 行, 每个线程所要完成的工作; 19 行, 初始化线程共享的全局变量初始化有两种方式, 一种是它要求主线程显示地调用一个初始化函数; 第二种是, 在此显示的, 当第一次有某个线程调用 echo_cnt 函数时, 使用 pthread_once 函数去调用初始化函数
其他并发问题
1)线程安全
当用线程编写程序时, 我们必须小心地编写那些具有称为线程安全性属性的函数一个函数被称为线程安全的, 当且仅当被多个并发线程反复地调用时, 它会一直产生正确的结果如果一个函数不是线程安全的, 我们就说它是线程不安全的
我们能够定义出四个 (不想交的) 线程不安全函数类:
第一类: 不保护共享变量的函数
第二类: 保持跨越多个调用的状态的函数一个伪随机数生成器是这类线程不安全函数的简单例子 rand 函数是线程不安全的, 因为档期调用的结果依赖于前次调用的中间结果当调用 srand 为 rand 设置了一个终止后, 我们从一个但线程中反复地调用 rand, 能够预期得到一个可重复的随机数字序列
第三类: 返回指向静态变量的指针的函数某些函数, 例如 ctime 和 gethostbyname, 将计算结果放在一个 static 变量中, 然后返回一个指向这个变量的指针如果我们从并发线程中调用这些函数, 那么将可能发生灾难, 因为正在被一个线程使用的结果会被另一个线程悄悄地覆盖了
有两种方法来处理这类线程不安全函数一种选择是重写函数, 使得调用者传递存放结果的变量的地址这就消除了所有共享数据, 但是它要求程序员能够修改函数的源代码
如果线程不安全是难以修改或不可能修改的, 那么另外一种选择是使用加锁 - 拷贝技术基本思想是将线程不安全函数与互斥锁联系起来, 在每一个调用位置, 对互斥锁加锁, 调用线程不安全函数, 将函数返回的结果拷贝到一个私有的存储器位置, 然后对互斥锁解锁为了尽可能减少对调用者的修改, 你应该定义一个线程安全的包装函数, 它执行加锁 - 拷贝, 然后通过调用这个包装函数来取代对线程不安全函数的调用
第四类: 调用线程不安全函数的函数如果函数 f 调用线程不安全函数 g, 那么 f 就是线程不安全的吗? 不一定如果 g 是第二类资源, 即依赖于跨越多次调用的状态, 那么 f 也是线程不安全的, 而且除了重写 g 以为, 没有办法然而, 如果 g 是第一类或第三类函数, 那么只要你用一个互斥锁保护调用位置和任何得到的共享数据, f 仍然可能是线程安全的
2)可重入性
有一类重要的线程安全函数, 叫做可重入函数, 其特点在于它们具有这样一种属性: 当它们被多个线程调用时, 不会引用共享数据尽管线程安全和可重入有时会被用做同义词, 但是它们之间还是有清晰的技术差别的下图表示了可重入函数线程安全函数和线程不安全函数之间的集合关系可重入函数集合是线程安全函数的一个真子集
可重入函数通常比不可重入函数高效一些, 因为不需要同步操作
如果所有的函数参数都是传值传递(没有指针), 且所有的数据引用都是本地的自动栈变量(没有引用静态或全局变量), 则函数是显式可重入的, 无论如何调用, 都没有问题
允许显式可重入函数中部分参数用指针传递, 则隐式可重入的在调用线程时小心传递指向非共享数据的指针, 它才是可重入如 rand_r
可重入性同时是调用者和被调用者的属性
3)竞争
当一个程序的正确性依赖于一个线程要在另一个线程到达 y 点之前到达它的控制流中的 x 点时, 就会发生竞争
4)死锁
信号量引入了一种潜在的令人厌恶的运行时错误, 叫做死锁, 它指的是一组线程被阻塞了, 等待一个永远也不会为真的条件
避免死锁是很困难的当使用二进制信号量来实现互斥时, 可以用如下规则避免:
如果用于程序中每对互斥锁(s,t), 每个既包含 s 也包含 t 的线程都按照相同顺序同时对它们加锁, 则程序是无死锁的
来源: http://www.bubuko.com/infodetail-2503106.html