Linux系统编程第四章学习笔记
前言
本章论述了并发编程, 介绍了并行计算的概念, 指出了并行计算的重要性;比较了顺序算法与并行算法, 以及并行性与并发性;解释了线程的原理及其相对于进程的优势;通过示例介绍了Pthread 中的线程操作, 包括线程管理函数, 互斥品、连接、条件变址和屏障等线程同步工具;通过具体示例演示了如何使用线程进行并发编程, 包括矩阵计算、快速排序和用并发线程求解线性方程组等方法;解释了死锁问题, 并说明了如何防止并发程序中的死锁问题;讨论了信号量, 并论证了它们相对千条件变量的优点;还解释了支待Linux 中线程的独特方式。编程项目是为了实现用户级线程。它提供了一个基础系统来帮助读者开始工作。
1.并行计算导论
在早期, 大多数计算机只有一个处理组件, 称为处理器或中央处理器(CPU)。受这种硬件条件的限制, 计算机程序通常是为串行计算编写的。要求解某个问题, 先要设计一种算法. 描述如何一步步地解决问题, 然后用计算机程序以串行指令流的形式实现该算法。在只有一个CPU 的情况下, 每次只能按顺序执行某算法的一个指令和步骤。但是, 基千分治原则(如二叉树查找和快速排序等)的算法经常表现出高度的并行性, 可通过使用并行或并发执行米提高计算速度。并行计算是一种计算方案, 它尝试使用多个执行并行算法的处理器更快速地解决问题。过去, 由于并行计算对计算资源的大撇需求, 普通程序员很少能进行并行计算仑近年来, 随着多核处理器的出现, 大多数操作系统(如Linux)都支持对称多处理(SMP)。甚至对于普通程序员来说, 并行计箕也已经成为现实。显然, 计算的未来发展方向是并行计算。因此, 迫切需要在计算机科学和计算机工程专业学生的早期学习阶段引入并行计算。在本章中, 我们将介绍通过并发编程实现并行计算的基本概念和方法。
1.顺序算法与并行算法
在描述顺序算法时常用的方法是用一个begin-end 代码块列出算法, 如下图左侧所示。
begin-end代码块中的顺序算法可能包含多个步骤。所有步骤都是通过单个任务依次执行的,每次执行—个步骤C 当所有步骤执行完成时, 算法结束。 相反, 图的右侧为并行算法的描述, 它使用cobegin-coend代码块来指定并行莽法的独立任务。在cobegin-coend块中, 所有 任务都是并行执行的。 紧接荷cobegin-coend代码块的下一个步骤将只在所有这些任务完成之后执行。
2.并行性与并发性
并行算法只识别可并行执行的任务,但是它没有规定如何将任务映射到处理组件。 在理想情况下,并行算法中的所有任务都应该同时实时执行。然而,真正的并行执行只能在有多个处理组件的系统中实现,比如多处理牉或多核系统。在单CPU系统中, 一次只能执行一个任务。 在这种情况下 ,不同的任务只能并发执行, 即在逻辑上并行执行。 在单CPU系统中并发性是通过多任务处理来实现的,该内容已在第3幸中讨论过。在本章的最后,我们将在一个编程项目中再次讲解和示范多任务处理的原理和方法。
2.线程
1.线程原理
一个操作系统(OS)包含许多并发进程。在进程模型中,进程是独立的执行单元。每个进程在内核模式或用户模式下执行。当在用户模式下执行时,每个进程在一个唯一的地址空间中执行,该地址空间与其他进程分离。虽然每个进程都是一个独立的单元,但它只有一个执行路径。每当进程必须等待某些内容时,例如 I/O 完成事件,它就会被挂起,整个进程的执行就会停止。线程是在同一进程地址空间中的独立执行单元。在创建一个进程时,它在一个独特的地址空间中创建一个主线程。当进程开始时,它执行进程的主线程。只有一个主线程时,进程和线程之间几乎没有任何区别。但是,主线程可以创建其他线程。每个线程可以创建更多的线程,依此类推。所有线程都在同一进程地址空间中执行,但是每个线程是一个独立的执行单元。在线程模型中,如果一个线程被挂起,其他线程可以继续执行。除了共享进程的公共地址空间外,线程还共享许多进程的其他资源,如用户 ID、打开的文件描述符和信号等。一个简单的比喻是,进程是一个房子,它有一个房主(主线程)。线程是住在进程同一房子里的人。房子里的每个人都可以独立地进行活动,但是他们共享一些公共设施,例如同一邮箱、厨房和浴室等。历史上,大多数计算机供应商都支持他们自己专有的操作系统中的线程。这些实现在系统之间有很大的差异。目前,几乎所有的操作系统都支持 Pthreads,这是 IEEE POSIX 1003.1c(POSIX 1995)的线程标准。更多信息可以参考许多书籍(Buttlar et al. 1996)和关于 Pthreads 编程的在线文章(Pthreads 2017)。
2.线程的优点
线程比进程有许多优势。
(1).
线程的创建和切换更快:进程的上下文复杂且庞大。复杂性主要来自于管理进程映像的需求。例如,在具有虚拟内存的系统中,进程映像可以由许多称为页面的内存单元组成。执行时,部分页面位于内存中,而其他页面可能不在。操作系统内核必须使用多个页表和多层硬件辅助来跟踪每个进程的页面。创建新进程时,操作系统必须分配内存并为进程构建页表。而在进程内创建线程时,操作系统不必为新线程分配内存和构建页表,因为线程共享进程的相同地址空间。因此,创建线程比创建进程更快。此外,线程切换比进程切换更快,原因如下。进程切换涉及将一个进程复杂的页面环境替换为另一个进程的页面环境,这需要大量的操作和时间。相反,同一进程中的线程之间的切换要简单得多,速度更快,因为操作系统内核只需要切换执行点,而不需要更改进程映像。
(2).
线程响应更快:进程只有一个执行路径。当一个进程被挂起时,整个进程的执行就会停止。相反,当一个线程被挂起时,同一进程中的其他线程可以继续执行。这使得带有线程的程序更具响应性。例如,在具有多个线程的进程中,当一个线程被阻塞等待I/O时,其他线程仍然可以在后台进行计算。在具有线程的服务器中,服务器可以同时为多个客户提供服务。
(3).
线程更适合并行计算:并行计算的目标是利用多个执行路径更快地解决问题。基于分而治之原则的算法,例如二分查找和快速排序等,通常表现出很高程度的并行性,可以通过使用并行或并发执行来加速计算。这样的算法常常要求执行实体共享公共数据。在进程模型中,进程无法高效地共享数据,因为它们的地址空间都是不同的。为解决这个问题,进程必须使用进程间通信(IPC)来交换数据,或者使用其他方式将一个共享数据区域包含在它们的地址空间中。相反,同一进程中的线程共享同一地址空间中的所有(全局)数据。因此,使用线程编写并行执行的程序比使用进程更简单、更自然。
3.线程的缺点
另一方面,线程也有一些缺点,包括:
(1).由于地址空间共享,线程需要用户显式同步。
(2).许多库函数可能不是线程安全的,例如传统的strtok()函数,它会按行将字符串分成标记。通常,任何使用全局变量或依赖静态内存内容的函数都不是线程安全的。需要做出相当大的努力来将库函数适应线程环境。
(3).在单CPU系统上,使用线程解决问题实际上比使用顺序程序更慢,因为在线程创建和运行时进行上下文切换的开销更大。
3.线程操作
线程的执行位置类似于进程。线程可以在内核模式或用户模式下执行。在用户模式下,线程在进程的相同地址空间中执行,但每个线程都有自己的执行堆栈。作为一个独立的执行单元,线程可以向操作系统内核发起系统调用,遵循内核的调度策略,被挂起、激活继续执行等。为了充分利用线程的共享地址空间,操作系统内核的调度策略可能对同一进程中的线程优先于不同进程中的线程。
目前,几乎所有的操作系统都支持POSIX Pthreads,它定义了一组标准的应用程序编程接口(API)来支持线程编程。接下来,我们将讨论并示范Linux中使用Pthreads进行并发编程的方法
4.线程管理函数
Pthreads库提供以下API用于线程管理。
pthread_create(thread, attr, function, arg):
创建线程 pthread_exit(status):
终止线程 pthread_cancel(thread):
取消线程 pthread_attr_init(attr):初始化线程属性 pthread_attr_destroy(attr):销毁线程属性。
1.创建线程
线程是由pthread_create()函数创建的。
int pthread_create (pthread_t *pthread_id, pthread_attr_t *attr,
void *(*func)(void *), void *arg);
当成功时返回0,当失败时返回错误号。pthread_create()函数的参数为: .pthread_id是指向pthread_t类型变量的指针。它将被分配给由操作系统内核分配的唯一线程ID。在POSIX中,pthread_t是一种不透明类型。程序员不应该了解不透明对象的内容,因为它可能取决于实现。线程函数可以通过pthread_self()函数获得自己的ID。在Linux中,pthread_t类型被定义为unsigned long,因此可以将线程ID打印为%lu.
.attr是指向另一个不透明数据类型的指针,它指定线程属性,下面将更详细地解释这些属性。
.func是新线程要执行的函数的入口地址。
.arg是指向线程函数的参数的指针,可以表示为:
void *func(void *arg)。
其中,属性参数是最复杂的参数之一。使用属性参数的步骤如下。 (1).定义一个pthread属性变量pthread_attr_t attr。
(2).使用pthread_attr_init(&attr)初始化属性变量。
(3).设置属性变量并在pthread_create()调用中使用它。
(4).如果需要,使用pthread_attr_destroy(&attr)释放属性资源。
以下是使用属性参数的一些示例。默认情况下,每个线程都被创建为可以与其他线程连接(joinable)。如果需要,可以创建一个具有分离(detached)属性的线程,这将使它无法与其他线程连接(joinable)。以下代码段显示如何创建一个分离线程。
pthread_attr_t attr; //定义一个属性变量 pthread_attr_init(&attr); //初始化属性 pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); //设置属性 pthread_create(&thread_id, &attr, func, NULL); //使用属性创建线程。
pthread_attr_destroy(&attr); //可选:销毁属性 每个线程都使用默认的栈大小创建。在执行过程中,一个线程可以通过函数 size_t pthread_attr_getstacksize() 查找其栈大小,该函数返回默认的栈大小。以下代码段显示如何创建具有特定栈大小的线程。
pthread_attr_t attr; //属性变量 size_t stacksize; //栈大小 pthread_attr_init(&attr); //初始化属性 stacksize = 0x10000; //stacksize=16KB; pthread_attr_setstacksize(&attr, stacksize); //在attr中设置栈大小 pthread_create(&threads[t], &attr, func, NULL); // 使用栈大小创建线程
如果attr参数为NULL,则将使用默认属性创建线程。实际上,这是创建线程的推荐方式,除非有强烈的理由改变线程属性。在以下内容中,我们将始终使用默认属性,将attr设置为NULL。
2.线程ID
线程ID是一种不透明的数据类型,它取决于实现。因此,不应直接比较线程ID。如果需要,可以通过pthread_equal()函数进行比较。 int pthread_equal(pthread_t t1, pthread_t t2); 该函数返回零,如果线程是不同的线程,则返回非零。
3.线程终止
线程在线程函数完成时终止。或者,线程可以调用函数 int pthread_exit(void *status); 显式地终止,其中status是线程的退出状态。通常情况下,一个零退出值表示正常终止,非零值表示异常终止。
4.线程连接
一个线程可以通过 int pthread_join(pthread_t thread, void **status_ptr); 等待另一个线程的终止。终止线程的退出状态将在status_ptr中返回。
5.线程示例程序
1.用线程计算矩阵的和
假设我们想计算一个 N × N 整数矩阵中所有元素的和。该问题可以使用线程来使用并发算法解决。在这个例子中,主线程首先生成一个N × N整数矩阵。然后它创建N个工作线程,将一个唯一的行号作为参数传递给每个工作线程,并等待所有工作线程终止。每个工作线程计算不同行的部分和,并将部分和存储在全局数组 int sum[N] 的相应行中。当所有工作线程完成时,主线程继续。它通过添加工作线程生成的部分和来计算总和。最后,打印总和。
#include <stdio.h>
#include <pthread.h>
#define N 100
int a[N][N]; //输入矩阵
int sum[N]; //数组用于存储部分和
int p = 0; //每个线程计算的行的索引
pthread_mutex_t mutex; //互斥锁,用于保护对p的访问
void *compute_sum(void *arg) {
int myid = *(int *)arg;
int sub_total = 0;
// 计算myid行的部分和
for (int j = 0; j < N; j++) {
sub_total += a[myid][j];
}
// 在sum数组的相应行中存储部分和
pthread_mutex_lock(&mutex);
sum[myid] = sub_total;
p++;
pthread_mutex_unlock(&mutex);
pthread_exit(NULL);
}
int main() {
// 初始化输入数组
for (int i = 0; i < N; i++) {
for (int j = 0; j < N; j++) {
a[i][j] = i + j;
}
}
// 初始化互斥锁
pthread_mutex_init(&mutex, NULL);
// 创建工作线程
pthread_t threads[N];
for (int i = 0; i < N; i++) {
int *arg = malloc(sizeof(int));
*arg = i;
pthread_create(&threads[i], NULL, compute_sum, arg);
}
// 等待工作线程终止
for (int i = 0; i < N; i++) {
pthread_join(threads[i], NULL);
}
// 计算总和
int total = 0;
for (int i = 0; i < N; i++) {
total += sum[i];
}
printf("Total sum is %d\n", total);
// 销毁互斥锁
pthread_mutex_destroy(&mutex);
return 0;
}
在这个例子中,每个线程通过遍历输入矩阵的每列来计算不同行的部分和。然后将部分和存储在全局数组 sum 的相应行中。主线程等待所有工作线程完成,然后通过累加部分和来计算总和。
使用互斥锁保护对索引 p 的访问,该索引跟踪下一个要由工作线程计算的行。最初,p = 0,每个工作线程检索唯一值的p,增加p并计算检索行的部分和。互斥锁用于确保每次只有一个线程访问共享变量p。
2.用线程快速排序
在这个例子中,我们将通过线程实现并行快速排序程序。当程序启动时,它作为一个进程的主线程运行。主线程调用qsort(&arg)函数,其中arg是一个范围为[lowerbound=0, upperbound=N-1]的数组。qsort()函数实现了对包含N个整数的数组的快速排序。
在qsort()函数中,线程选择一个基准元素将数组分成两个部分,左部分包含小于基准的元素,右部分包含大于基准的元素。然后,它创建两个子线程分别对两部分进行排序,并等待子线程完成。每个子线程使用相同的递归算法对其自己的范围进行排序。
当所有子线程都完成时,主线程恢复执行。它打印排序后的数组并终止执行。众所周知,快速排序的排序步骤取决于未排序数据的顺序,这影响了qsort程序中所需的线程数量。
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#define MAX_NUMBERS 1000
int numbers[MAX_NUMBERS];
int num_threads;
typedef struct {
int lower;
int upper;
} thread_arg_t;
void *quicksort_thread(void *arg) {
thread_arg_t *p = (thread_arg_t *)arg;
int lower = p->lower, upper = p->upper;
if (lower < upper) {
// select pivot element
int pivot = numbers[upper];
int i = lower - 1;
// partition array
for (int j = lower; j <= upper - 1; j++) {
if (numbers[j] < pivot) {
i++;
int temp = numbers[i];
numbers[i] = numbers[j];
numbers[j] = temp;
}
}
int temp = numbers[i + 1];
numbers[i + 1] = numbers[upper];
numbers[upper] = temp;
int mid = i + 1;
// sort left and right parts
if (num_threads > 1) {
// create two threads
pthread_t tid[2];
thread_arg_t args[2];
args[0].lower = lower;
args[0].upper = mid - 1;
args[1].lower = mid + 1;
args[1].upper = upper;
for (int i = 0; i < 2; i++) {
pthread_create(&tid[i], NULL, quicksort_thread, &args[i]);
}
for (int i = 0; i < 2; i++) {
pthread_join(tid[i], NULL);
}
} else {
quicksort_thread(&((thread_arg_t){lower, mid - 1}));
quicksort_thread(&((thread_arg_t){mid + 1, upper}));
}
}
pthread_exit(NULL);
}
void quicksort(int n) {
if (num_threads == 1) {
quicksort_thread(&((thread_arg_t){0, n - 1}));
} else {
// create thread for main sorting task
pthread_t tid;
thread_arg_t arg = {0, n - 1};
pthread_create(&tid, NULL, quicksort_thread, &arg);
pthread_join(tid, NULL);
}
}
int main(int argc, char *argv[]) {
if (argc < 2) {
printf("Usage: %s <num_threads>\n", argv[0]);
exit(EXIT_FAILURE);
}
int n = MAX_NUMBERS;
num_threads = atoi(argv[1]);
// generate random numbers
for (int i = 0; i < n; i++) {
numbers[i] = rand() % n;
}
// sort numbers
quicksort(n);
// print sorted numbers
for (int i = 0; i < n; i++) {
printf("%d ", numbers[i]);
}
printf("\n");
return 0;
}
该程序将命令行参数中给定的线程数作为参数,生成一个包含随机整数的数组,然后使用线程进行快速排序,最后打印排序后的数组。程序中的
quicksort_thread()
函数是递归的,并且在某些情况下创建两个子线程来排序数组的左右两部分。在main函数中,该程序使用
pthread_create()
函数创建线程,并使用
pthread_join()
函数等待线程完成其任务
6.线程同步
由于线程在进程的相同地址空间中执行,因此它们共享该地址空间中的所有全局变量和数据结构。当多个线程尝试修改相同的共享变量或数据结构时,如果结果取决于线程的执行顺序,则称之为竞争条件。在并发程序中,不能存在竞争条件。否则,结果可能不一致。除了join操作外,同时执行的线程经常需要相互协作。为了防止竞争条件,并支持线程协作,线程需要同步。一般而言,同步是指用于确保共享数据对象完整性和并发执行实体协调的机制和规则。它可以应用于内核模式中的进程或用户模式中的线程。以下,我们将讨论Pthreads中线程同步的具体问题。
1.互斥量
最简单的同步工具是锁,它只允许执行实体在拥有锁的情况下继续执行。在Pthreads中,锁被称为互斥量(mutex),它的作用是互斥。互斥量变量的类型为pthread_mutex_t,使用前必须进行初始化。有两种方法可以初始化互斥量变量。
2.死锁预防
互斥量使用锁定协议。如果一个线程无法获取互斥量锁,它会被阻塞,等待互斥量解锁后再继续执行。在任何锁定协议中,滥用锁可能会导致问题。最知名和突出的问题是死锁。死锁是一种情况,其中许多执行实体相互等待对方,因此它们中没有一个可以继续执行。为了说明这一点,假设一个线程T1已经获取了互斥量m1的锁,并尝试锁定另一个互斥量m2。另一个线程T2已经获取了互斥量m2的锁,并尝试锁定互斥量m1,如下图所示。
T1: lock(m1) T2: lock(m2)
lock(m2) lock(m1)
在这种情况下,T1和T2互相等待对方永远释放对m2和m1的锁定请求,因此它们由于交叉的锁定请求而陷入了死锁。与无竞态条件类似,死锁不能存在于并发程序中。处理可能的死锁有许多方法,包括死锁预防、死锁避免、死锁检测和恢复等。在实际系统中,唯一实际可行的方法是死锁预防,在设计并行算法时尽量避免死锁的发生。一种简单的预防死锁方法是对互斥量进行排序,并确保每个线程只以单一方向请求互斥量锁定,这样请求序列中就不会出现循环。然而,并不是每个并行算法都能够只使用单向锁定请求进行设计。在这种情况下,可以使用条件锁定函数pthread_mutex_trylock()来避免死锁。trylock()函数会立即返回错误,如果互斥量已经被锁定。在这种情况下,调用线程可以通过释放一些已经持有的锁定来进行退避,让其他线程继续执行。在上面的交叉锁定示例中,可以重新设计其中一个线程,比如T1,如下所示,使用条件锁定和退避来避免死锁的发生。
3.条件变量
互斥锁只用作锁定,确保线程以互斥的方式访问共享数据对象的临界区域。条件变量提供了线程之间协作的方式。条件变量总是与互斥锁一起使用。这并不奇怪,因为互斥是所有同步机制的基础。在Pthreads中,条件变量的声明类型为pthread_cond_t,并且在使用前必须进行初始化。与互斥锁类似,条件变量也可以通过两种方式进行初始化。
4.生产者-消费者问题
5.信息量
信号量是进程同步的通用机制。一个(计数)信号量是一个数据结构
struct sem{
int value; // 信号量(计数器)的值;
struct process *queue // 一个阻塞进程的队列
}s;
在使用前,需要使用一个初始值和一个空的等待队列初始化一个信号量。无论硬件平台是单CPU系统还是多处理系统,信号量的低级实现都保证每个信号量一次只能被一个执行实体操作,而对信号量的操作是原子的(不可分割的)或者从执行实体的角度来看是原始的。读者可能会忽略这些细节,关注于信号量的高级操作及其作为进程同步机制的使用。信号量最著名的操作是P和V(Dijkstra 1965),其定义如下。
6.屏障
线程的join操作允许一个线程(通常是主线程)等待其他线程的终止。在所有等待的线程都终止后,主线程可以创建新的线程来继续执行并行程序的下一部分。这需要创建新线程的开销。在某些情况下,最好保持线程处于活动状态,但要求它们在达到指定的同步点之前不进行继续。在Pthreads中,提供了屏障机制,以及一组屏障函数。首先,主线程创建一个屏障对象
pthread_barrier_t barrier;
并调用
pthread_barrier_init(&barrier, NULL, nthreads);
以使用在屏障处要进行同步的线程数量对其进行初始化。然后,主线程创建工作线程来执行任务。工作线程使用以下函数来进行同步:
pthread_barrier_wait(&barrier);
该函数会阻塞工作线程,直到所有线程都达到屏障,在此处它们都可以并发地继续执行任务。屏障可以重复使用多次,在同一个并行程序中允许线程多次进行同步。一旦并行程序完成,主线程调用
pthread_barrier_destroy(&barrier);
以释放为屏障对象分配的内存。屏障是一个非常有用的机制,可以避免在需要线程间同步的情况下创建新线程的开销。
pthread_barrier_wait(&barrier)函数用于在屏障处等待,直到达到指定数量的线程。当最后一个线程到达屏障处时,所有线程恢复执行。在这种情况下,屏障是线程汇合点,而不是新线程的 graveyard(坟墓)。我们通过以下示例来说明屏障的使用。
7.用并发线程理解线性方程组
我们通过一个示例来演示并发线程和线程join和屏障操作的应用。
示例4.6:
该示例是通过并发线程解决线性方程组的问题。假设AX = B是一个线性方程组,其中A是一个N × N的实数矩阵,X是一个包含N个未知数的列向量,而B是一个常数列向量。问题是计算解向量X。解决线性方程组的最常用算法是高斯消元法。该算法包括两个主要步骤:行约减和回带。在行约减步骤中,将合并矩阵[A | B]缩减为上三角形式,然后在回带步骤中计算解向量X。在行约减步骤中,部分主元选取是一种方案,对于该方案,选取的用于缩减其他行的行的主元具有最大绝对值。部分主元选取有助于提高数值计算的精度。
8.Linux中的线程
不同于许多其他操作系统,Linux不区分进程和线程。对于Linux内核来说,线程只是一个共享某些资源的进程。在Linux中,进程和线程都是通过clone()系统调用创建的,其原型为:
int clone(int (*fn)(void *), void *child_stack, int flags, void *arg)
正如可以看到的那样,clone()更像是一个线程创建函数。它创建一个子进程来执行一个带有child_stack的函数fn(arg)。标志字段指定要由父进程和子进程共享的资源,包括
CLONE_VM:父进程和子进程共享地址空间
CLONE_FS:父进程和子进程共享文件系统信息,例如root。 CWD
CLONE_FILES:父进程和子进程共享打开的文件
CLONE_SIGHAND:父进程和子进程共享信号处理程序和阻塞信号
如果指定了任何一个标志,则两个进程共享完全相同的资源,而不是资源的单独副本。如果未指定标志,则子进程通常会获得资源的单独副本。在这种情况下,由一个进程对资源所做的更改不会影响另一个进程的资源。Linux内核保留fork()作为系统调用,但它可以实现为调用具有适当标志的clone()的库包装器。普通用户不必担心这些细节。可以说Linux具有一种高效支持线程的方法。此外,大多数当前的Linux内核支持对称多处理(SMP)。在这样的Linux系统中,进程(线程)被调度以在多处理器上并行运行。