一、梗概
本章论述了并发编程,介绍了并行计算的概念,指出了并行计算的重要性;比较了顺序算法与并行算法,以及并行性与并发性;解释了线程的原理及其相对于进程的优势;通过示例介绍了 Pthread 中的线程操作,包括线程管理函数,互斥量、连接、条件变量和屏障等线程同步工具;通过具体示例演示了如何使用线程进行并发编程,包括矩阵计算、快速排序和用并发线程求解线性方程组等方法;解释了死锁问题,并说明了如何防止并发程序中的死锁问题;讨论了信号量,并论证了它们相对于条件变量的优点;还解释了支持 Linux 中线程的独特方式。编程项目是为了实现用户级线程。它提供了一个基础系统来帮助读者开始工作。这个基础系统支持并发任务的动态创建、执行和终止,相当于在某个进程的同一地址空间中执行线程。
二、知识点总结
1、并行计算
简单来讲,并行计算就是同时使用多个计算资源来解决一个计算问题:
1)一个问题被分解成为一系列可以并发执行的离散部分;
2)每个部分可以进一步被分解成为一系列离散指令;
3)来自每个部分的指令可以在不同的处理器上被同时执行;
4)需要一个总体的控制/协作机制来负责对不同部分的执行情况进行调度。
这里的 计算问题 需要具有如下特点:
能够被分解成为并发执行离散片段;
不同的离散片段能够被在任意时刻执行;
采用多个计算资源的花费时间要小于采用单个计算资源所花费的时间。
这里的 计算资源 通常包括:
具有多处理器/多核(multiple processors/cores)的计算机;
任意数量的被连接在一起的计算机。
2、线程
线程(thread)是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。在Unix System V及SunOS中也被称为轻量进程(lightweight processes),但轻量进程更多指内核线程(kernel thread),而把用户线程(user thread)称为线程。
线程是独立调度和分派的基本单位。线程可以为操作系统内核调度的内核线程,如Win32线程;由用户进程自行调度的用户线程,如Linux平台的POSIX Thread;或者由内核与用户进程,如Windows 7的线程,进行混合调度。
3、线程管理
1)创建一个新的线程
点击查看代码
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,void *(*start_routine) (void *), void *arg);
如果成功则返回 0,如果失败则返回错误代码。pthread_createO 函数的参数为
·pthread_id 是指向 pthread_t类型变量的指针。它会被操作系统内核分配的唯一线利 ID 填充。在POSIX中,pthread_t是一种不透明的类型。程序员应该不知道不透明对象的内容,因为它可能取决于实现情况。线程可通过 pthread_selfO 函数获得自,的 ID。在 Linux 中,pthread_t类型被定义为无符号长整型,因此线程 ID可以打白
·attr 是指向另一种不透明数据类型的指针,它指定线程属性,下面将对此进行更详细的说明
.func 是要执行的新线程函数的人口地址。.arg 是指向线程函数参数的指针,可写为:
void *func(void *arg)
其中,attr 参数最复杂。下面给出了 attr 参数的使用步骤。
(1)定义一个 pthread 属性变量 pthread_attr_t attr。
(2)用pthread attr init (&attr)初始化属性变量。
(3)设置属性变量并在 pthread create( 调用中使用。
(4)必要时,通过pthread attr destroy (&attr) 释放 attr 资源。
2)线程终止
void pthread_exit(void *retval);
3)等待线程结束
int pthread_join(pthread_t thread, void **retval);
4)返回线程id
pthread_t pthread_self(void);
3、线程同步
当多个控制线程共享相同的内存时,需要确保每个线程看到一致的数据视图。如果每个线程使用的变量都是其他线程不会读取或修改的,那么就不会存在一致性问题。同样地,如果变量是只读的,多个线程同时读取该变量也不会有一致性问题。但是,当某个线程可以修改变量,而其他线程也可以读取或修改这个变量的时候,就需要对这些线程进行同步,以确保它们在访问变量的存储内容时不会访问到无效的数值。
1)互斥量
可以通过使用pthread的互斥接口保护数据,确保同一时间只有一个线程访问数据。互斥量(mutex)从本质上说是一把锁,在访问共享资源前对互斥量进行加锁,在访问完成后释放互斥量上的锁。对互斥量进行加锁以后,任何其他试图再次对互斥量加锁的线程将会被阻塞直到当前线程释放该互斥锁。如果释放互斥锁时有多个线程阻塞,所有在该互斥锁上的阻塞线程都会变成可运行状态,第一个变为运行状态的线程可以对互斥量加锁,其他线程将会看到互斥锁依然被锁住,只能回去再次等待它重新变为可用。在这种方式下,每次只有一个线程可以向前执行。
互斥变量用pthread_mutex_t数据类型表示,在使用互斥变量以前,必须首先对它进行初始化,可以把它置为常量PTHREAD_MUTEX_INITIALIZER(只对静态分配的互斥量),也可以通过调用pthread_mutex_init函数进行初始化。如果动态地分配互斥量(例如通过调用malloc函数),那么在释放内存前需要调用pthread_mutex_destroy。
点击查看代码
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr);
int pthread_mutex_destroy(pthread_mutex_t *mutex);```
返回值:若成功则返回0,否则返回错误编号。
要用默认的属性初始化互斥量,只需把attr设置为NULL。
对互斥量进行加锁,需要调用pthread_mutex_lock,如果互斥量已经上锁,调用线程将阻塞直到互斥量被解锁。对互斥量解锁,需要调用pthread_mutex_unlock。
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:若成功则返回0,否则返回错误编号。
2)死锁预防
如果线程试图对同一个互斥量加锁两次,那么它自身就会陷入死锁状态,使用互斥量时,还有其他更不明显的方式也能产生死锁。例如,程序中使用多个互斥量时,如果允许一个线程一直占有第一个互斥量,并且在试图锁住第二个互斥量时处于阻塞状态,但是拥有第二个互斥量的线程也在试图锁住第一个互斥量,这时就会发生死锁。因为两个线程都在相互请求另一个线程拥有的资源,所以这两个线程都无法向前运行,于是就产生死锁。
可以通过小心地控制互斥量加锁的顺序来避免死锁的发生。例如,假设需要对两个互斥量A和B同时加锁,如果所有线程总是在对互斥量B加锁之前锁住互斥量A,那么使用这两个互斥量不会产生死锁(当然在其他资源上仍可能出现死锁);类似地,如果所有的线程总是在锁住互斥量A之前锁住互斥量B,那么也不会发生死锁。只有在一个线程试图以与另一个线程相反的顺序锁住互斥量时,才可能出现死锁。
3)信号量
信号量是进程同步的一般机制。(计数)信号量是一种数据结构
struct sem{
int value; //semaphore(counter) value;
struct process *queue // a queue of blocked processes
}s;
在使用信号量之前,必须使用一个初始值和一个空等待队列进行初始化。不论是什么硬件平台,即无论是在单 CPU 系统还是多处理器系统上,信号量的低级实现保证了每次只能由一个执行实体操作每个信号量,并且从执行实体的角度来看,对信号量的操作都是(不可分割的)原子操作或基本操作。读者可以忽略这些细节,将重点放在信号量的高级操作及其作为进程同步机制的使用上。
4)屏障
线程连接操作允许某线程(通常是主线程)等待其他线程终止。在等待的所有线程都终上后,主线程可创建新线程来继续执行并行程序的其余部分。创建新线程需要系统开销。在某些情况下,保持线程活动会更好,但应要求它们在所有线程都达到指定同步点之前不能继活动。在 Pthreads 中,可以采用的机制是屏障以及一系列屏障函数。首先,主线程创建一个屏障对象
pthread_barrier_t barrieri
并且调用
pthread_barrier_init(&barrier NULL,nthreads);
用屏障中同步的线程数字对它进行初始化。然后,主线程创建工作线程来执行任务。工作线程使用
pthread_barrier_wait( &barrier)
在屏障中等待指定数量的线程到达屏障。当最后一个线程到达屏障时,所有线程重新开始执行。在这种情况下,屏障是线程的集合点,而不是它们的坟墓。