应用级并发应用情况:访问慢速I/O设备;与人交互;通过推迟工作以降低延迟;服务多个网络客户端;在多核机器上进行并发计算。

三种基本构造并发程序的方法:进程、I/O多路复用、线程

1、基于进程的并发编程

例如构建一个并发服务器:

假设有1个服务器和2个客户端,服务器正在监听listenfd(3)上的连接请求,客户端1向服务端请求,服务端返回一个连接描述符connfd(4),同时服务器会生成一个子进程,子进程完全复制服务器描述符表,并关闭其监听符listenfd(3),服务器关闭已连接的描述符connfd(4)。客户端2请求时,同样派生一个子进程服务。

优劣:

父子进程间共享文件列表,但不共享用户地址空间,一个进程不可能覆盖另一个进程的虚拟内存。但缺点是独立的地址空间使得进程共享状态信息更困难,需要IPC机制(管道,消息队列,共享内存,),比较缓慢。

2、基于I/O多路复用的并发编程

适用场景:1)网络客户端发起连接请求 2)用户在键盘上键入命令行

基本思想:用select函数将进程挂起,只有在一个或多个I/O事件发生后,才将控制返回给应用程序。

3、基于线程的并发编程

线程就是运行在进程上下文中的逻辑流。线程包括线程ID、栈、栈指针、程序计数器、通用目的寄存器和条件码。

线程执行模式:先有一个主线程,主线程创建一个对等线程,两个线程并发执行。

多线程和多进程的区别:1、一个线程的上下文要比一个进程的上下文小很多,切换更快。2、线程不是按照严格的父子层次来组织的,对等线程池的概念,一个线程可以杀死它的任何对等线程。

POSIX线程(pthreads):C程序处理线程的一个标准接口。

1)创建线程:pthread_create

int pthread_create(pthread_t *tid,pthread_attr_t *attr,func *f,void *arg);

tid存线程的ID;attr参数可以改变线程的默认属性;f是新线程上下文中运行的线程例程;arg表示输入变量

2)终止线程:

四种方式:当顶层的线程例程(函数)返回时,线程会隐式地终止;通过pthread_exit()来显示终止;调用exit()函数,会终止进程以及所有与该进程相关的线程;另一个线程通过当前线程的ID并调用pthread_cancel(pthread_t tid)来终止线程。

3)回收已终止线程的资源:

用pthread_join(pthread_t tid,void *thread_return)来等待一个线程终止,将线程返回的void 指针赋值给thread_return指向的位置,并回收已终止线程占用的内存资源。

4)分离线程:

线程的两种状态:结合态(可以被其他线程回收和杀死)和分离态(内存空间由系统自动释放)

默认线程是结合态,为了避免内存泄漏,可以通过pthread_detach(pthread_t tid)分离,线程可以通过pthread_detach(pthread_self())来自我分离。

5)初始化线程:

pthread_once(ptread_once_t once_control,void (init_routine)(void))函数允许你初始化与线程例程相关的状态。pthread_once_t once_control=PTHREAD_ONCE_INIT是一个全局变量,第一次用once_control为参数调用pthread_once时,将调用init_routine来初始化。

6)多线程程序中的共享变量

线程内存模型:线程具有独立的上下文(包括线程ID、栈、栈指针、程序计数器、条件码、通用目的寄存器值),同一进程的不同线程共享进行上下文的剩余部分(包括代码、读写数据、堆)。

将变量映射到内存:

全局变量:在运行时,虚拟内存只包含每个全局变量的一个实例,任何线程都可以引用。

本地自动变量:定义在函数内部但没有static 属性的变量,存在于每个线程的栈中。

本地静态变量:定义在函数内部有static属性的变量,在虚拟内存里。

7)用信号量同步线程

(a)用共享变量的方***引入同步错误,因此提出信号量的概念.信号量s是具有非负整数的全局变量,有两种特殊的操作:

P(s):如果s非零,则将s减1,并立即返回。如果s为0,则挂起当前线程,直到s变为非0。

V(s):将s加1。如果有线程阻塞在P操作等待s变为非0,V操作会重启这些线程中的一个,然后该线程会将s减1,但重启的顺序没有定义。

P和V的操作不可分割,确保一个正在运行的程序不会进入一种状态,就是一个正确初始化的信号量由负值。称为信号量不变性。

POSIX中的函数:int sem_init(sem_t *sem,0,unsigned int value)初始化信号量sem为value。int wait_init(sem_t *s)对应于P(s)。int sem_post(sem_t *s)对应于V(s)。

(b)使用信号量来实现互斥

将每个共享变量和一个信号量s(初始值为1)联系起来,然后用P(s)和V(s)操作将相应的临界区包围起来,这种方式的信号量叫二元信号量。以提供互斥为目的的二元信号量称为互斥锁。在一个互斥锁上执行P操作称为对互斥锁加锁。执行V操作称为对互斥锁解锁。

例如:

先声明一个信号量mutex:

volatile long cnt = 0;sem_t mutex;

主例程将mutex初始化为1:
sem_init(&mutex,0,1);

在线程中对共享变量cnt的更新包围P和V操作

for(i = 0;i<niters;i++){
    P(&mutex);cnt++;V(&mutex);

}

(c)利用信号量来调度共享资源

一个线程用信号量操作来同质另一个线程,程序状态中某个条件为真。

生产者-消费者问题:生产者线程—>有限的缓冲区—>消费者线程

需要3个信号量:mutex信号量提供互斥的缓冲区访问,slots和items信号量分别记录空槽位和可用项目的数量。

读者-写者问题:
一组并发的线程要访问一个共享对象,写者必须拥有对对象的独占的访问,读者可以和无限多个其他读者共享对象。

8)其他并发问题

线程安全:当多个并发线程反复调用一个函数,该函数可以得到正确的结果,则该函数是线程安全的。

四类线程不安全的函数:不保护共享变量的函数;保持跨越多个调用的状态的函数;返回指向静态变量的指针的函数(通过加锁-复制解决);调用线程不安全函数的函数。

9)竞争

当一个程序的正确性依赖于一个线程要在另一个线程到达y点之前到达它的控制流中的x点是,就会发生竞争。通过动态的为每个参数分配一个独立的块,并传递给线程例程一个指向这个块的指针。

10)死锁

指的是一组线程被阻塞,等待一个永远不会为真的条件。程序死锁是因为每个线程都在等待其他线程执行一个不可能发生的V操作。

解决死锁的方法:互斥锁加锁顺序规则(给定所有互斥操作的一个全序,如果每个线程都以一种顺序会的互斥锁并以相反的顺序释放,那么这个程序就是无死锁的)