如果逻辑控制流在时间上是重叠,那么它们就是并发的(concurrent)
。这种常见的现象称为并发(concurrency)
。
我们主要将并发
看做是一种操作系统内核用来运行多个应用程序的机制。
但是,并发
不仅仅局限于内核。它也可以在应用程序中扮演重要的角色。
例如
Unix
信号处理程序如何允许应用响应异步事件 ctrl-c
其他情况
访问慢速I/O
设备
I/O
设备(例如磁盘)的数据到达时,内核会运行其他进程,使CPU保持繁忙。与人交互
通过推迟工作以降低延迟
并发
来降低某些操作的延迟使用应用级并发的应用程序称为并发程序(concurrent program)
.
操作系统提供三种基本的构造并发程序的方法:
进程
每个逻辑控制流 都是一个进程
因为进程
有独立的虚拟地址空间
通信
,控制流必须使用某种显式的进程间通信(interprocess communication,IPC)
进制I/O
多路复用(暂时不太懂) 逻辑流
。逻辑流
被模型化为状态机
,数据到达文件描述符后,主程序显式地从一个状态转换到另一个状态。线程
是运行在一个单一进程上下文中的逻辑流
,有内核调度。 进程
一样由内核进行调度。I/O
多路复用一流一样共享一个虚拟地址空间。一个构造并发服务器的自然方法就是,在父进程中接收客户端连接请求,然后创建一个新的子进程来为每个新客户端提供服务。
描述符3
)上的连接请求客户端1
的连接请求描述符4
)。描述符3,4
)监听描述符3
描述符4
用Signal(SIGCHLD,sigchld_handler)
回收僵死进程。
8.5.7
28
行,33
行 父子进程各自关闭他们不需要的拷贝。
因为文件表项的引用计数,直到父进程关闭它的描述符,才算结束一次连接
对于在父,子进程间共享状态信息,进程有一个非常清晰的模型
。
进程
拥有独立的虚拟地址空间即是 优点,也是 缺点。
优点
:一个进程
不可能不小心覆盖另一个进程的虚拟存储空间。
缺点
:独立的地址空间使得进程间共享信息也很困难。
必须使用显式的IPC
(进程间通信)机制。
往往还比较慢
IPC
的开销都很大。假设要编写一个echo服务器
。
服务器
既能响应客户端
的请求exit
).因此,服务器
必须要响应两个相互独立的I/O
事件
无论先等待那个事件都不是理想的,解决办法之一是就是使用I/O多路复用技术
。
select
函数,要求内核挂起进程,只有一个或多个I/O
事件发生后,才将控制返回给应用程序。线程(thread)
就是运行在进程上下文中的逻辑流。
内核
调度。每个线程都有它自己的线程上下文(thread context)
.
线程ID(Thread ID,TID)
.所有运行在该进程里的线程
共享该进程的整个虚拟地址空间。
每个进程开始生命周期时都是单一线程,这个线程称为主线程(main thread)
。
对等线程(peer thread)
。 read
或sleep
间隔计时器
中断。对等线程
。对等线程
执行一段时间,将控制传递回主线程。在某些方面,线程
执行是不等同于进程的。
线程
的上下文切换的开销比进程
的小得多,快得多线程
不是按照严格的父子层次来组织。 线程池(pool)
。 线程池
概念的主要影响是对等线程
终止。Posix线程
(Pthreads
)是在C程序中处理线程的一个标准接口。
Unix
系统可用这是我们第一个线程化的代码,仔细解析。
线程的代码和本地数据被封装在一个线程例程(thread routine)
中。
2
行代码所示:每个线程例程
都以一个通用指针作为输入,并返回一个通用指针。如果想传递多个参数给线程例程
指针
。如果想要线程例程
返回多个参数。
指针
。tid
存放对等线程的线程ID
。
主线程调用pthread_create
函数创建一个新的对等线程(第7
行)。
pthread_create
的调用返回时,主线程和新创建的对等线程同时运行。通过调用pthread_join
,主线程等待对等线程
的终止。
对等线程
输出Hello,world
。
主线程
终止。线程通过调用pthread_create
函数来创建其他线程。
#include<phread.h>
typedef void *(func)(void *);
int phread_create(pthread_t *tid,pthread_attr_t *attr,fun *f,void *arg)
//若成功则返回0,出错则为非0
pthread_create
函数创建一个新的线程。
arg
,在新线程的上下文中运行线程例程f
.能用attr
参数改变新创建线程的默认属性。
NULL
作为attr
的参数。pthread_create
返回时,参数tid
包含新创建线程的ID
。
pthread_self
函数来获得它自己的线程ID
。一个线程是以下列方式之一来终止
的
线程例程
返回时,线程会隐式地终止
。通过调用pthread_exit
函数,线程会显示地终止
。
pthread_exit
. thread_return
原型如下
#include<pthread.h>
void pthread_exit(void *thread_return)
//成功返回0,出错返回非0
某个对等线程调用Unix
的exit
函数,函数终止进程和所有与该进程有关的线程
。
对等线程
通过以当前线程ID为参数调用pthread_cancle
函数来终止当前线程。
原型
#include<pthread.h>
void pthread_cancle(pthread_t tid);
//成功返回0,出错返回非0
线程通过调用pthread_join
函数等待其他进程终止
#include<pthread.h>
int pthread_join(pthread_t tid,void **thread_return);
//返回,成功则为0,出错为非0
pthread_join
函数会阻塞,知道线程tid
终止,将线程返回的(void *
)指针赋值给thread_return
所指向的位置,然后回收已终止线程占用的存储器资源。
pthread_join
不像wait
函数一样等待任意一个线程的结束。
Stevens
在书中指出这是一个设计错误。在任何一个时间点上,线程是可结合的(joinable)
或者 是分离的(detached)
。
一个可结合的线程
能够被其他线程收回其资源或者杀死。
一个分离的线程
是不能被其他线程收回其资源或者杀死。
pthread_detach
函数分离可结合线程tid
。
#include<pthread.h>
int pthread_detach(pthread_t tid);
返回:若成功则返回0,若出错则返回非零。
pthread_once
函数允许你初始化与线程例程相关的状态。
#include<pthread.h>
pthread_once_t once_control = PTHREAD_INIT;
int phread_once(phread_once_t *once_control,void (*init_routine)(void));
once_control
变量是一个全局或者静态变量,总是被初始化为PTHREAD_ONCE_INIT
.当你第一次用参数once_control
调用pthread_once
时,它调用init_routine
。
第二次,第三次以参数once_control
调用pthread_once
时,啥事也不发生。
当你需要动态初始化多个线程共享的全局变量时,pthread_once
函数是很有用的。
注意使用malloc
动态给一个connfdp
,否则可能两个线程引用同一个connfdp
的地址。
竞争
为在线程例程
中避免存储器泄露,使用分离线程
。
主线程
中malloc
的变量。为了解一个C程序中的一个变量是否共享,有一些基本的问题要解答
基础存储器模型
是什么?变量实例
是如何映射到存储器的?为了使共享讨论具体化,使用下图的程序作为示例。
示例程序由一个创建两个对等线程
的主线程组成。主线程传递一个唯一的ID
给每个对等线程,每个对等线程利用这个ID
输出一个个性化的信息,以及调用该线程例程
的总次数。
线程化的C程序中的变量根据它们的存储类型被映射到虚拟存储器:
全局变量
全局变量
是定义在函数之外的变量。 读/写区域
包含每个全局变量的一个实例。线程
都可以引用。5
行声明的ptr
。本地自动变量
本地自动变量
就是定义在函数内部但是没有static
属性的变量。 栈
包含它自己的所有本地自动变量的实例。本地静态变量
本地静态变量
是定义在函数内部有static
属性的变量。 读/写区域
。25
行的cnt
.我们说一个变量v
是共享
的,当期仅当它的一个实例被一个以上的线程
引用。
例如:
cnt
是共享的myid
不是共享的msgs
这种本地自动变量也能被共享是很重要的。共享变量十分方便,但是他们也引入了同步错误(synchronization error)
的可能性。
考虑下图的程序。
到底哪里出错了呢?这个错误十分隐晦
,必须通过研究计数器循环
时的汇编代码才能看出。
当badcnt.c
中的两个对等线程在一个单处理器上并发执行
,机器指令以某种顺序一个接一个地完成。同一个程序每次运行的顺序都可能不同,这些顺序
中有一些将会产生正确结果,但是其他的不会。这就是同步错误
关键点
: 一般而言,你没有办法预测操作系统是否将为你的线程选择一个正确的顺序。
cnt
正确的顺序和错误的顺序(正确结果cnt=2
,错误结果cnt=1
)我们可以借助于一种叫做进度图(progress graph)
的方法来阐明这些正确和不正确的指令顺序的概念。将在接下来介绍。
进度图(process graph)
将n
个并发进程的执行模型化为一条n
维笛卡尔空间的轨迹线
。
每条轴k
对应于k
的进度。
每个点(I1,I2,I3,I4...,In)
代表线程k(k=1,...,n)
已经完成到了Ik
这条指令的状态。
图的原点对应于没有任何线程完成这一条指令的初始状态
。
进度图
将指令执行模型化为从一个状态到另一个状态的转换(transition)
。
转换
指从一点到相邻一点的有向边。 合法的转换
是向各个轴的正半轴走。对于线程i
,操作共享变量cnt
内容的指令(Li,Ui,Si)
构成了一个(关于共享变量cnt
的)临界区(critical section)
。(必须确保指令要这样执行)
这个临界区
不应该和其他线程的临界区
交替执行。(这一段的指令不能交叉)。
我们要确保每个线程在执行它的临界区中的指令时,拥有对共享变量的互斥的访问(mutually exclusive access)
。
互斥(mutual exclusion)
。在进程图中,两个临界区的交集形式称为不安全区(unsafe region)
。
安全轨迹线
。 不安全轨迹线
。我们必须以某种方式同步线程
,使它们总是有一条安全轨迹线
Edsger Dijksta
,并发编程领域的先锋任务,提出了一种经典的解决同步不同执行线程问题
的方法
这种方法是基于一种叫做信号量(semaphore)
的特殊类型变量。
信号量s
是具有非负整数值的全局变量。
只能由两种特殊的操作来处理,这两种操作称为P
和V
P(s),Proberen,测试
s
是非零的,那么P操作
将s
减1,并且立即返回。s
为零,那么就挂起这个线程,直到s
变为非零。 V
操作会重启这个线程。P操作
将s
减1,并将控制返回给调用者。V(s),Verhogen,增加
V操作
将s
加1.P操作
等待s
变成非零。 V操作
随机会重启这些线程中的一个。s
减去1,完成它的P操作
。重点,P操作
和V操作
都是不可分割的,也就是自身确保了是一个带有安全轨迹的操作。(所以又叫原语
)
cnt++
的操作。加1
这个操作中,加载,加一,存储信号量过程是不可分割的。P
和V
的定义确保了一个正在运行的程序绝不可能进入这样一种状态,也就是不可能有负值。
这个属性叫做信号量不变性(semaphore invariant)
,为控制并发程序的轨迹线提供了强有力的工具。
信号量
提供了一种很方便的方法来确保对共享变量的互斥访问。
基本的思想是
共享变量
(或一组相关的共享变量) 与一个信号量s(初始为
)`联系起来。P(s)
和V(s)
操作相应的临界区包围起来。以这种方式保护共享变量的信号量叫做二元信号量(binary semaphore)
以提供互斥为目的的二元信号量
常常也称为互斥锁(mutex)
。
P操作
叫做互斥锁加锁
。V操作
叫做互斥锁解锁
。占用这个互斥锁
。一个被用作一组可用资源的计数器的信号量称为计数信号量
。
关键思想:
P操作
和V操作
的结合创建了一组状态,叫做禁止区(forbidden regin)
,其中s<0
信号量的不变形
,不可能有轨迹线进入这个区域禁止区
包含了不安全区
的任何部分。 正确实现上文中的cnt
的线程同步。
第一步:声明一个信号量 mutex
volatile int cnt = 0 ;
sem_t mutex;
第二步:主线程中初始化
Sem_init(&mutex,0,1);
第三步,在线程例程中对共享变量cnt
的更新包围P
和V
操作,从而保护了它们。
for( i = 0 ;i < niters ;i++) {
P(&mutex);
cnt++;
V(&mutex);
}
除了提供互斥
外,信号量的另一个重要作用是调度对共享资源的访问。
两个经典而有用的例子。
图给出了生产者消费者问题
生产者线程
反复地生成新的项目
,并把它们插入到缓冲区中。消费者线程
不断地从缓冲区取出这些项目
,然后消费使用它们。因为插入和取出项目都涉及更新共享变量
互斥的
缓冲区
的访问。 我们将开发一个简单的包,叫做SBUF
,用来构造生产者-消费者程序。
SBUF
操作类型为sbuf_t
的有限缓冲区。
项目
存放在一个动态分配的n
项整数数组(buf
)中。front
和rear
索引值记录该队列的第一项和最后一项。mutex
信号量提供互斥的缓冲区访问slots
和items
信号量分别记录空槽位和可用项目的数量。以下给出SBUF
函数的实现:
sbuf_init
函数进行初始。 front
和rear
表示一个空的缓冲区。sbuf_deinit
函数是当应用程序使用完缓冲区时,释放缓冲区存储。sbuf_insert
sbuf_remove
读者-写着
问题是互斥问题的一个概括。
一组并发的线程要访问同一个数据对象。
写者
读者
写者
必须拥有对对象的独占访问。
读者
可以和无限多个其他读者共享对象。读者-写者
问题有几个变种,都是基于读者和写者的优先级
第一类读者-写者问题
读者
优先,要求不要让读者等待,除非已经把一个使用权限赋予了一个写者
。读者
不会因为有一个写者
在等待而等待。第二类读者-写者问题(?)
写者
优先,要求一但一个写者准备好可以写,它就会尽可能地完成它的写操作。给出第一类读者-写者问题答案。
信号量w
控制对访问共享对象的临街区的访问。
读者
w
只对第一个读者上锁w
对最后一个走的读者解锁写者
w
上锁w
解锁mutex
保护对共享变量readcnt
的访问。 readcnt
统计当前临界区的读者数量。所有读者-写者
答案都有可能导致饥饿
为每个新的客户端创建新的线程,有不少的代价。
一个基于预线程化
的服务器利用生产者-消费者模型构造一个更高效率的方式。
主要用于多核CPU的算法。
比如:利用并行来完成n路递归
互斥
和生产者-消费者同步
的技术,只是并发问题的冰山一角。
同步问题
从根本来说是很难的问题。
这章我们以线程
为例讨论。
同步问题
在任何并发流
操作共享资源时都会出现。 信号
时,回收进程时的竞争
。一个函数被称为线程安全的(thread-safe)
,当且仅当被多个并发线程反复地调用时,它会一直产生正确的结果。否则就是线程不安全的(thread-unsafe)
我们能够定义出四个
(不相交)线程不安全函数类:
P,V
这样的同步操作来保护共享的变量第 2 类 : 保持跨越多个调用状态的函数
第 3 类 :返回指向静态变量的指针的函数( 有点类似第一类 )
第 4 类 : 调用线程不安全函数的函数。
f
调用线程不安全函数g
。那么f
可能不安全。 g
是第二类,那么f
一定不安全,也没有办法去修正,只能改变g
.g
是第一,三类,可以用加锁-拷贝技术来解决。有一类重要的线程安全函数
,叫做可重入函数(reentrant function)
其特点在于它们有这样一种属性。
共享数据
。被分为两类
隐式可重入
参数可以有指针
是否可重入,同时取决于调用者
,和被调用者
。
可重入函数
比较高效是因为不需要同步操作。
认识到可重入性有时即是调用者
也是被调用者
的属性。
被调用者
的单独属性。大多数Unix
函数,包括大部分定义在标准C库的函数(malloc
,free
,realloc
,printf
和scanf
)都是线程安全的。
asctime
,ctime
,localtime
函数是在不同时间和数据格式相互来回转换时经常使用的函数。
gethostbyname
,gethostbyaddr
,inet_ntoa
函数是经常用的网络编程函数。
strtok
函数是一个过时了的同来分析字符串的函数。
Unix
系统提供大多数线程不安全函数的可重入版本。
_r
后缀结尾。gethostbyname_r
。当一个程序的正确性依赖于一个线程要在另一个线程到达y
点之前到达它的控制流中的x
点,就会发生竞争
。
竞争
发生的理由是因为程序员假定某种特殊的轨迹线穿过执行状态空间。例子:
程序十分简单。
主线程创建了四个对等线程
,并传递一个指向循环变量i
的指针作为线程的ID
。并输出。
i
一定是四个不同的。所以会想当然觉得会输出四个不同的ID
。对等线程
给myid
赋值结束后,i
才会自增。i++
,和对等线程myid=*((int *)vargp)
的 竞争
。解决方案:用一个临时地址保存i
信号量
引入了一种潜在的令人厌恶的运行时错误,叫做死锁 (deadlock
)。
进度图
对于理解死锁是一个无价的工具。
死锁的区域d
是一个只能进,不能出的区域。
禁止区
,能进去。禁止区
了。如果禁止区不重叠,一定不会发生死锁
。
死锁
。死锁是一个相当困难的问题,因为它总是不可预测的。
死锁
区域。使用二元信号量来实现互斥,可以应用一下有效的规则。
互斥锁加锁顺序规则
:如果对于程序中每对互斥锁(s,t)
,每个占用s
和t
的线程都按照相同的顺序对它们加锁,那么这个程序就是无死锁的。
GGGGGGGGGGG,暂时告一段落了!!!!!!!!!!!!!!ddd!!