并发程序:使用应用级并发
进程驱动
- 将每个逻辑控制流视为进程。
- 内核自动管理流,进程管理开销较大。
- 流必须通过IPC进行通信。
- 难以共享资源,但同时也避免了可能带来的共享问题
例如服务器的父进程接受不同客户端的请求,然后创建1个子进程为客户提供服务。
事件驱动
- 将流视为状态机:
- 状态
- 输入事件
- 转移(根据当前状态和输入事件转移到下一个状态)
服务器通过I/O多路复用响应不同的I/O事件。select函数首先挂起进程(阻塞),在有I/O事件发生后,将控制返回给应用程序。
例如在读集合中保存描述符的类型,在有I/O事件发生后,如果导致有描述符准备好可读,退出select函数。程序通过宏指令确定描述符的类型,进行相应的操作。- 手动控制多个逻辑流
- 所有的逻辑流共享同一个地址空间
- 编码复杂,无法提供精细度较高的并行
- 无法充分利用多核处理器
- 将流视为状态机:
线程驱动
线程:运行在进程上下文的逻辑流。- 每个线程都有线程上下文(线程 ID,栈,栈指针,PC,条件码,GP 寄存器),共享剩下的进程上下文,切换更快。
- 一个进程可以有多个线程,即作为单独可执行的部分从进程中抽离出来。
- 位于同一进程的线程组成对等线程池
- 主线程是第1个运行的线程
属于基于进程和基于事件的混合体:
- 内核自动管理多个线程
- 多个线程运行在进程上下文,共享地址空间
- 每个线程独立线程 id,保存局部变量的栈(其他线程可以修改)但是会共享所有的代码、数据以及内核上下文。
- 共享变量(实例被多个线程引用)可能会造成同步错误,难以调试和测试。
- 全局变量:定义在函数之外。每个线程引用同一实例。
- 本地自动变量:函数内部的非static变量。每个线程有自己的实例。
- 本地静态变量:函数内部的非static变量。每个线程引用同一实例。
volatile long cnt=0;//共享变量
sem_t mulex;//互斥锁
void *thread(void *vargp);
int main()
{
pthread_t tid1,tid2;
Sem_init(&mutex,0,1);//初始化信息量
…
/*进程1*/
Pthread_creat(&tid1,NULL,thread,&niters);
/*进程2*/
Pthread_creat(&tid2,NULL,thread,&niters);
…
}
void *thread(void *vargp)
{
long i,niters=*((long *)vargp);
for(i=0;i<niters;i++)
{
P(&mutex);
cnt++;
V(&mutex);
}
return NULL;
}
Pthreads:C语言处理线程的标准接口
- 线程例程:封装线程代码和本地数据
- pthread_creat函数:利用线程例程创建线程,生成线程ID。返回后与主线程同时运行。
- pthread_join函数:等待指定线程终止,并回收内存资源
- pthread_exit函数:终止线程。当主线程调用时会等待其他对等线程终止后再终止自己和所在进程。
注意当线程调用Linux的exit()函数时,将会终止进程及其所有线程。 - pthread_detach函数:将指定的线程分离(不能被其他线程回收或杀死,内存资源由系统自动释放)
将线程例程的循环分成5段:
- H:第1次循环前进行条件判断
- L:将cnt加载到%rdx
- U:更新%rdx
- S:将%rdx的值存回
- T:更新循环变量并进行条件判断
其中i位于栈中,程序运行时无法确定CPU执行指令的顺序是否会产生正确结果。如图12-18(a)所示:CPU执行线程1的例程时将更新后的值返回存储后再执行线程2的例程;但是(b)执行线程1的例程时,在更新cnt后并没有存储就执行线程2的例程,此时cnt依然为0.
根据CP702的进程图:
- 横坐标为线程1的运行时间;纵坐标为线程2的运行时间。
- 由于程序不会反向运行,且同一时间只能有1条指令运行。所以箭头只能向上(执行线程2的指令)或者向右移动(执行线程1的指令)。
- 将操作cnt的指令划为不安全区。一旦进入说明某个线程没有完成对共享变量的操作。
- 多线程的程序必须保证任何可行的轨迹线都正常工作。
信号量:非负整数的全局变量
- 互斥
将共享变量和互斥锁(二元信息量:初始化为1)联系起来。
P(互斥锁加锁)和V(互斥锁解锁)操作保证了程序的信号量不变性(信号量在初始化后保持非负),确保了程序对临界区的互斥访问。在进程图中加入P(S)和V(S)。
- P(S)
//P操作的包装函数
int sem_wait(sem_t *s);
企图进入不安全区的线程需要先运行 P。当 S 为非负时减1并返回,线程可以获准进入不安全区;当信号量 S=0时,线程会被挂起,直到信号量S不为负值时。
- V(S)
//V操作的包装函数
int sem_post(sem_t *s);
结束离开临界区块的线程将会运行 V,将信号量 S 的值加1。选择1个被P阻塞的线程将其重启,获准进入不安全区。
- 调度对共享资源的访问
线程用信号量通知另一线程,程序状态的某个条件为真。 - 生产者-消费者问题
- 生产者将项目存储到缓存区,并通知消费者
- 消费整从缓存区中移除项目,并通知生产者
在此模型中,不仅需要对缓冲区的互斥访问;还考虑到生产者/消费者在缓冲区为满/空时必须等待。
为实现一个有 n 个元素 缓冲区,需要一个 mutex 和两个用来计数的 semaphore:
typedef struct {
…
sem_t mutex; // 保证对 buffer 的互斥访问
sem_t slots; // 统计 buffer 中可用的空槽数目
sem_t items; // 统计 buffer 中可用的项目数目
} sbuf_t;
//为缓冲区分配堆内存
void sbuf_init(sbuf_t *sp, int n);
//为缓冲区分配堆内存
void sbuf_deinit(sbuf_t *sp);
//等待空槽位,对互斥锁加锁,添加项目,对互斥锁解锁,发送消息
void sbuf_insert(sbuf_t *sp, int item);
//等待项目,对互斥锁加锁,取出项目,对互斥锁解锁,发送消息
int sbuf_remove(sbuf_t *sp);
- 读者-写者问题
并发的线程访问共享对象
- 读者:只读对象,可以共享
- 写者:修改对象,独占对对象的访问
例如电影票预订系统允许多个用户查看座位分配;但预订座位的用户必须独占对数据库的访问。
- 读者优先:只有没有读者的情况下,写者才能工作
int readcnt;//共享变量
sem_t mutex;//保护共享变量
sem_t w;//信号量
void writer()
{
while(1){
P(&w);//保证只有1个写者访问
…
V(&w);
}
}
void reader()
{
while(1){
P(&mutex);
readcnt++;
//第1个读者进入时
if(readcnt==1) P(&w);
V(&mutex);
…
P(&mutex);
readcnt--;
//最后1个读者离开时
if(readcnt==1) V(&w);
V(&mutex);
}
}
2 写者优先:只有没有写者的情况下,读者才能工作
可能导致starvation(线程无限期的阻塞)
总结:
- 相同点:有自己的逻辑控制流,可以并行,都需要进行上下文切换。
不同点:线程共享代码和数据(进程通常不会),线程开销比较小(创建和回收)
线程安全函数:
被多个并发线程调用时会始终产生正确的结果。
可重入函数:
被多线程调用时不会引用共享数据。由于不需要同步,所以效率更高。
- 显式可重入:函数参数为传值传递,不引用共享变量
- 隐式可重入:函数参数包含指向非共享数据的指针
线程不安全的函数:
不保护共享变量的函数
- 解决办法:使用 P 和 V semaphore 操作
- 问题:同步操作会影响性能
在多次调用间保存状态的函数
- 解决办法:把状态当做传入参数
- 返回指向静态变量的指针的函数
- 解决办法1:重写函数,传地址用以保存
- 解决办法2:上锁,并且进行复制
调用线程不安全函数的函数
竞争:
线程2到达y点之前,线程1必须到达x点。
#define N 4
void *thread(void *vargp);
int main()
{
pthread_t tid[N];
int i;
for(i=0;i<N;++i)
Pthread_creat(&tid[i],NULL,thread,&i);
…
}
主线程在创建对等线程时传递了本地变量i的指针。参考之前的进程图,只有在线程2执行完thread函数后,线程1执行++i程序才会得到正确的结果。
所以必须用另一变量存储i的值,并将其作为参数传入。
死锁
线程被无限期阻塞
由于2个线程使用P和V的顺序不当,箭头只能向上/右移动,所以箭头在进入死锁区后,必定将进入s/t的禁止区。
互斥锁加锁顺序:
- 每个线程以一种顺序获得互斥锁
- 以相反顺序释放