chapter 4:并发编程

发布时间 2023-10-28 20:39:35作者: 20211108俞振阳

chapter 4:并发编程

学习笔记:并发编程

4. 并发编程

  • 概要
    • 介绍了并发编程的概念,对比了顺序算法与并行算法,以及并行性与并发性之间的区别。
    • 解释了线程的原理及其优势,覆盖了在Pthreads中的线程操作。
    • 展示了通过示例实现线程的并发编程,如矩阵计算、快速排序以及通过并发线程解决线性方程组的问题。
    • 解释了死锁问题以及如何预防在并发程序中的死锁。
    • 涵盖了信号量的内容,并展示了其相对于条件变量的优势。
    • 阐述了Linux中支持线程的独特方式。

4.1 并行计算介绍

  • 早期情况
    • 计算机通常只有一个处理元素,即处理器或中央处理单元(CPU)。
    • 由于硬件限制,传统上计算机程序是为串行计算而编写的。
  • 并行计算的发展
    • 随着多核处理器的出现,大多数操作系统(如Linux)支持对称多处理(SMP),并行计算已经成为普通程序员的现实。
  • 未来方向
    • 计算的未来显然是朝着并行计算的方向发展。
    • 为了让计算机科学和计算机工程的学生能够更早地接触到并行计算,有必要在早期阶段引入并发编程的基本概念和技术。

4.1.1 顺序算法 vs 并行算法

  • 区别
    • 顺序算法是串行执行的,而并行算法可以并行执行多个任务。
    • 并行算法采用cobegin-coend块来指定并行算法的不同任务,实现多个任务并行执行。

4.1.2 并行性 vs 并发性

  • 概念对比
    • 并行算法只标识可以并行执行的任务,但不指定如何将任务映射到处理元素。
    • 在单CPU系统中,不同的任务只能以并发方式执行,即逻辑上的并行。
    • 在单CPU系统中,通过多任务处理实现并发性,本章末尾的编程项目将重新解释和演示多任务处理的原理和技术。

4.2 线程

4.2.1 线程原理

  • 操作系统模型
    • 进程中的线程是相对独立的执行单元,处于相同的地址空间。
  • 比喻
    • 进程像是一个房子,而线程就像是在同一个房子里生活的人,各自独立活动但共享一些共同资源。
  • Pthreads支持
    • 几乎所有操作系统支持Pthreads,这是IEEE POSIX 1003.1c(POSIX 1995)的线程标准。

4.2.2 线程的优势

  • 优势
    • 线程创建和切换更快,因为线程共享相同的地址空间,无需额外的内存和页面表。
    • 线程更具响应性,一个线程被阻塞时,其他线程仍可继续执行。
    • 更适合并行计算,因为线程可以更自然地共享数据,不像进程需要进行进程间通信(IPC)来交换数据。

4.2.3 线程的劣势

  • 劣势
    • 因为线程共享地址空间,需要用户显式同步。
    • 许多库函数可能不支持线程安全,需要调整以适应线程环境。
    • 在单CPU系统中,线程的开销可能导致运行速度比串行程序更慢。

4.3 线程操作

  • 执行位置
    • 线程可以在内核模式或用户模式下执行,拥有各自的执行栈。
  • Pthreads支持
    • 几乎所有操作系统都支持POSIX Pthreads,提供了一套标准的API用于支持线程编程。

参考书目:Buttlar et al. 1996, Pthreads 2017, Goldt et al 1995, IBM, Love 2005, Linux Man Page Project 2017.

4.4 线程管理函数

  • Pthreads库API
    • 提供以下线程管理API:
      • pthread_create: 创建线程
      • pthread_exit: 终止线程
      • pthread_cancel: 取消线程
      • pthread_attr_init: 初始化线程属性
      • pthread_attr_destroy: 销毁线程属性

4.4.1 创建线程

  • pthread_create()函数创建线程,其参数包括:
    • pthread_create(pthread_t *pthread_id, pthread_attr_t *attr, void *(*func)(void *), void *arg)
  • attr参数是最复杂的,步骤如下:
    1. 定义pthread_attr_t attr线程属性变量。
    2. 通过pthread_attr_init(&attr)初始化属性。
    3. 设置属性变量,使用在pthread_create()调用中。
    4. 如果需要,通过pthread_attr_destroy(&attr)释放资源。
  • 示例展示了如何创建一个非可连接线程。

4.4.2 线程ID

  • 线程ID是一个不透明的数据类型,应避免直接比较。可以使用pthread_equal()函数进行比较。

4.4.3 线程终止

  • 线程在函数结束时终止,也可以通过pthread_exit()显式终止。退出状态为0表示正常终止,非零值表示异常终止。

4.4.4 线程加入

  • 一个线程可以通过pthread_join()等待另一个线程的终止,获取其退出状态。

4.5 线程示例程序

4.5.1 矩阵求和

  • 示例展示了如何通过多线程计算N × N矩阵中所有元素的总和。
  • 主线程创建N个工作线程,每个线程计算一个独立行的部分和,存储在全局数组中。
  • 最后,主线程将所有部分和相加,得到总和。

4.5.2 快速排序

  • 通过并发线程实现了一个并行快速排序程序。
  • 主线程调用qsort()函数,其中使用了快速排序算法。
  • 在算法中,创建子线程对两个分区进行递归排序,然后等待子线程结束。

4.6 线程同步

线程共享的问题

  • 线程在同一个进程的地址空间中执行,共享所有全局变量和数据结构。
  • 当多个线程尝试修改相同的共享变量或数据结构时,如果结果依赖于线程的执行顺序,则称为“竞争条件”。
  • 竞争条件在并发程序中不应存在,否则结果可能不一致。

线程同步和互斥锁

  • 同步是用于确保共享数据对象的完整性和并发执行实体协调的机制和规则。
  • 为了防止竞争条件并支持线程协作,线程需要同步。
  • 在Pthreads中,互斥锁是最简单的同步工具,用于实现互斥。

4.6.1 互斥锁(Mutex Locks)

  • 互斥锁是一种锁,允许执行实体仅在拥有锁的情况下继续执行。
  • 在Pthreads中,互斥锁被称为互斥量(mutex),用pthread_mutex_t类型声明。
  • 互斥量需要初始化,可以通过静态或动态方式进行。
    • 静态方式使用 pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER; 进行初始化。
    • 动态方式使用 pthread_mutex_init(&m, NULL); 进行初始化。
  • 互斥锁使用函数 pthread_mutex_lockpthread_mutex_unlockpthread_mutex_trylockpthread_mutex_destroy
  • 互斥锁用于保护共享数据对象,典型使用是在关键区域(Critical Region)中对共享数据进行访问。

4.6.2 死锁预防

  • 死锁是一种情况,其中多个执行实体相互等待,导致无法继续。
  • 避免死锁是关于并发程序处理可能死锁的唯一实际方式。方法包括死锁预防、死锁避免、死锁检测和恢复等。
  • 死锁预防试图在设计并行算法时防止死锁,例如通过互斥量的顺序化和条件锁的使用。

4.6.3 条件变量(Condition Variables)

  • 条件变量用于线程协作,与互斥锁一起使用。
  • 在Pthreads中,条件变量使用 pthread_cond_t 类型声明,需要初始化。
  • 条件变量与互斥锁结合使用,包括 pthread_cond_waitpthread_cond_signalpthread_cond_broadcast 等函数。

4.6.4 生产者-消费者问题

  • 实现生产者-消费者问题的简化版本,使用线程和条件变量。
  • 生产者和消费者共享有限数量的缓冲区,条件变量用于等待空缓冲区或满缓冲区。

4.6.5 信号量(Semaphores)

  • 信号量是用于进程同步的通用机制。Pthreads中的信号量函数包括 sem_initsem_waitsem_post
  • 与条件变量相比,信号量具有计数器,并在关键区域内进行操作,适用于更广泛的场景。

4.6.6 屏障(Barriers)

  • 屏障允许一组线程在同步点等待,然后一起继续执行。通过 pthread_barrier_initpthread_barrier_wait 来实现。

4.6.7 通过并发线程解线性方程组

  • 使用线程来解线性方程组,通过高斯消元算法并利用屏障和线程等待操作。

4.6.8 Linux中的线程

  • 在Linux中,线程和进程没有区别,都是通过 clone() 系统调用创建。
  • clone() 可以创建共享资源的子进程,包括内存、文件系统、打开文件等。

并发编程知识总结

本章介绍了并发编程的概念。首先解释了并行计算的重要性,对比了顺序算法和并行算法,以及并行与并发的区别。接着阐述了线程的原理,强调了线程相对于进程的优势。内容涵盖了Pthreads中线程的操作方法,如线程的管理函数、互斥锁、线程等待、条件变量和屏障。通过详细示例演示了如何使用线程进行并发编程,包括矩阵计算、快速排序以及使用并发线程解决线性方程组。章节也解释了死锁问题,提供了避免并发程序中死锁的方法。讨论了信号量的特点,并展示了它们相对于条件变量的优势。另外,还介绍了Linux支持线程的独特方式。最后的编程项目是实现用户级线程,为读者提供了基础系统,支持并发任务的动态创建、执行和终止,类似线程在同一进程的地址空间中执行。编程项目要求读者实现线程的连接、互斥锁和信号量,以展示它们在并发编程中的应用。

问题

  1. 修改示例程序C4.1

    • 通过并发线程找到N×N矩阵的最大元素值。
  2. 在示例程序C4.2的qsort()函数中

    • 使用未排序间隔的最左侧元素作为枢纽点。比较两种情况下所需的排序步骤数。
  3. 修改示例程序C4.4

    • 创建多个生产者和消费者线程。
  4. 使用信号量实现生产者-消费者问题

    • 参考第4.5节的示例4.5。
  5. 在示例程序C4.5中

    • 将工作线程的数量N修改为NTHREADS,其中NTHREADS <= N。例如,当N=8时,NTHREADS可以是2、4或任何小于等于N的数字。
  6. 修改示例程序C4.1

    • 在MT系统中使用并发任务计算矩阵中所有元素的和。结果应与第4.1节中的示例相同。
  7. 实现texit()和join()

    • 在用户级线程MT系统中,确保每个任务都由其他任务连接。当系统结束时,不应该留下僵尸任务。

苏格拉底挑战

点击查看问答过程 并发编程原子性理解