20231326《密码系统设计》十二周预习报告

20231326《密码系统设计》十二周预习报告

目录

学习内容

《Head First C 嗨翻 C 语言》 第12章:线程

1. 为什么需要线程

  • 并行需求: 很多程序(如游戏、聊天程序、媒体播放器)需要同时执行多个任务,例如一边更新屏幕,一边读取用户输入。
  • 进程的局限性: 虽然进程(Process)可以实现并行,但它们不适用于所有场景:
    • 创建耗时: 新建进程需要时间,对于只需要几十毫秒的任务来说效率低下。
    • 共享不便: 子进程只拥有父进程数据的副本,如果需要共享数据,必须通过管道等复杂的机制进行通信。
    • 代码复杂: 创建进程所需的代码量大,使得程序冗长复杂。
  • 线程的优势: 线程(Thread)允许在一个进程中雇佣多名"员工",可以快速启动任务,共享现有数据,并减少代码量。

2. 线程与进程的区别

  • 共享性: 进程是独立的,拥有自己的地址空间;而同一进程中的所有线程可以访问同一段堆存储器、读写同一个文件,以及使用同一个网络套接字。
  • 阻塞性: 如果一个线程在等待数据(阻塞操作),其他线程可以继续运行,不会打断整个进程。
  • 并发冲突: 线程虽然强大,但由于共享数据,它们之间可能发生冲突(即竞态条件),因此需要使用同步机制(如互斥锁)来保护共享数据的安全。

3. POSIX 线程 (pthread) 编程

POSIX 线程库(pthread)是在 C 语言中实现多线程最流行的一种方式。

  • 线程函数签名: 线程要执行的函数必须具有特定的签名:void* func(void *a),即返回类型和参数类型都为 void*
  • 创建线程: 使用 pthread_create() 系统调用来创建并运行线程。
    • 它将线程信息保存在 pthread_t 结构体中。
    • 创建函数需要进行错误检查。
  • 等待与回收线程:
    • 如果主程序运行结束,所有未结束的线程也会随之终止。
    • 必须使用 pthread_join(thread_t, &result) 函数来等待线程结束
    • pthread_join() 会接收线程函数的 void* 返回值,并回收线程资源。
  • 编译: 编译使用 pthread 库的程序时,必须链接该库,例如在 GCC 中使用 -lpthread 选项。

AI 对学习内容的总结

我的总结 补充细节
多任务机制: 线程是解决多任务并行执行的有效方案,旨在克服进程在启动速度和数据共享上的缺点。 并行场景: 游戏需要同时更新图形、计算物理参数、读取控制器输入,网络程序需要一边收发数据一边处理用户交互。
进程与线程: 进程是操作系统分配资源的独立单位,而线程是 CPU 调度的最小单位,被称为轻量级进程 线程状态: 线程拥有自己的栈空间、寄存器值,以及程序计数器,但在同一进程内共享代码段、数据段和堆。
共享内存: 同一进程内的线程可以访问同一段堆存储器(堆变量),当一个线程修改全局变量,其他线程马上就能看到。 内存模型: 线程共享全局变量、静态变量和堆内存,但各自拥有独立的栈(用于存储局部变量和函数调用信息)。
线程函数: 线程函数的返回类型和参数类型都必须是 void*,用于传递任意类型数据或状态。 类型转换: 使用 void* 是为了实现泛型,允许线程函数接收和返回任何类型的数据,但在使用时需要进行强制类型转换以还原真实类型。
核心操作: 使用 pthread_create() 创建线程,使用 pthread_join() 等待并回收线程资源。 pthread_t 作用: pthread_t 结构体是一个不透明的类型 (opaque type),用于在 pthread API 中唯一标识一个线程,但其内容对用户隐藏。
并发冲突: 线程的共享特性导致它们之间可能发生冲突(竞态条件),必须使用同步机制来保护共享数据。 保护范围: 只有对共享数据进行读-写写-写操作的代码段才需要加锁保护,纯读操作(不可变数据)通常是安全的。
同步机制: 最常见的保护机制是互斥锁(Mutex),它确保同一时间只有一个线程能访问受保护的共享资源(临界区)。 互斥锁操作: 互斥锁需要先通过 pthread_mutex_init() 初始化,然后通过 pthread_mutex_lock()pthread_mutex_unlock() 来控制访问。
编译要求: 使用 POSIX 线程库时,编译命令中必须加入 -lpthread 选项。 链接作用: -lpthread 选项告诉 GCC 链接器将程序与实现 POSIX 线程 API 的库文件(如 libpthread.a)连接起来。

学习思维导图

mindmap root C语言多任务处理: POSIX 线程 进程 vs 线程 进程 独立资源分配 创建慢, IPC复杂 线程 轻量级进程 CPU调度最小单位 创建快, 共享数据 堆/全局 POSIX线程 pthread 线程生命周期 创建: pthread_create 保存到 pthread_t 结束: 线程函数返回或 pthread_exit 回收: pthread_join 阻塞等待/取返回值 或 pthread_detach 自动回收 线程函数: void* func(void *arg) 线程安全与同步 竞态条件 Race Condition 现象: 多线程非原子操作共享数据 如 beers-- 的三步骤 互斥锁 Mutex 操作: init, lock, unlock, destroy 作用: 保护临界区 Critical Section 潜在问题 死锁 Deadlock: 线程相互等待锁 性能瓶颈: 频繁加解锁开销与阻塞等待

基于 AI 的学习


问题 1:

线程相对于进程的最大优势在于数据共享,但这也是最大的风险。为什么线程的数据共享会比进程间通信(IPC)更容易产生不可预期的结果(即"竞态条件"或 Race Condition)?

Gisli:

线程共享同一进程的地址空间,可以直接访问全局变量和堆内存,这提高了效率。然而,如果多个线程并发地对同一个共享变量执行读和写 操作,并且操作不是原子性的(不可分割的),那么最终的结果将取决于 CPU 调度顺序,导致结果不确定,从而产生竞态条件。进程间通信(如管道、消息队列)需要明确的数据传递步骤,是协作式的,数据流动有清晰的边界,因此风险更可控。

ChatGPT:

没错。书中提供了一个倒数啤酒瓶的例子(beers = beers - 1;),运行了 20 个线程,最终结果会出错。


问题 2:

请具体说明,为什么像 beers = beers - 1; 这样看似简单的 C 语言语句,在多线程环境下会造成竞态条件?它在底层执行时究竟发生了哪几个步骤?

Gisli:

在底层硬件指令层面,beers = beers - 1; 并非一个单独的原子操作,它通常分解为至少三个步骤:

  1. 加载 (Load):beers 当前的值从内存中加载到 CPU 寄存器中。
  2. 修改 (Decrement): 在寄存器中将该值减去 1。
  3. 存储 (Store): 将寄存器中的新值写回 beers 的内存地址。
    如果线程 A 执行了步骤 1 和 2 后,CPU 切换到线程 B。线程 B 此时执行 1、2、3 步,完成了一次正确的减 1 操作。然后 CPU 再次切换回线程 A 执行步骤 3,线程 A 存储的是它在切换前加载并修改的值。结果是,两次减 1 操作,内存中的 beers 值只减少了 1,发生了错误。

ChatGPT:

非常好,这就是对原子性 的理解。既然书上提到要用"红绿灯"来防止"车祸",那么在 pthread 中,最核心的"红绿灯"是什么?


问题 3:

pthread 库中,用于保护共享数据、实现互斥访问的最基本、最常见的同步机制是什么?它在代码层面是如何确保一段关键代码(Critical Section)只能被一个线程执行的?

Gisli:

最核心的同步机制是互斥锁(Mutex,Mutual Exclusion Lock)

  • 确保互斥: 互斥锁像一个令牌。当一个线程进入关键代码区域(临界区,例如执行 beers = beers - 1;)之前,它必须先调用 pthread_mutex_lock() 尝试获取锁。
  • 阻塞等待: 如果锁已被其他线程持有,该线程将被阻塞 (挂起),直到持有锁的线程调用 pthread_mutex_unlock() 释放锁。
  • 原子操作: 这保证了同一时间只有一个线程能够持有锁,从而独占地执行关键代码,解决了竞态条件。

问题 4:

如果在一个多线程程序中,所有的共享变量访问都使用了互斥锁来保护,程序是否就一定安全且高效?请指出使用互斥锁可能带来的两个主要问题。

Gisli:

使用互斥锁保护共享变量可以保证程序的线程安全(Thread Safety),但并不一定保证高效和完全安全。主要问题有:

  1. 性能开销(Inefficiency): 锁操作(加锁和解锁)本身需要 CPU 时间,频繁的加锁/解锁操作会显著降低程序的执行速度。此外,如果大量线程竞争同一个锁,未获得锁的线程会进入阻塞/等待状态,造成资源浪费。
  2. 死锁(Deadlock): 如果线程 A 获得了锁 1,同时试图获取锁 2;而线程 B 获得了锁 2,同时试图获取锁 1,那么两个线程都会永远等待对方释放锁,导致所有相关的线程永远卡住,造成死锁。

问题 5:

线程启动函数签名是 void* func(void *a)。如果线程需要访问主函数中的变量,最常见的方式是将其地址通过 void *a 传递进去。那么,为什么将主函数中定义的 **局部(栈)变量**的地址传递给子线程作为参数是非常危险的?

Gisli:

局部(栈)变量的生命周期只存在于它们被定义的函数调用期间。如果主函数(父线程)创建了子线程后立即返回,那么该局部变量所占用的栈内存就会被释放或用于新的函数调用。当子线程启动并尝试通过传递的地址访问该变量时,它访问到的可能是一块已被释放被覆盖 的内存,这会导致不可预知的行为,通常表现为程序崩溃或数据损坏。因此,共享数据必须存储在堆(使用 malloc)或全局/静态存储区,以保证在所有线程的生命周期内都有效。

ChatGPT:

很好,你理解了栈内存和线程生命周期的关系。


问题 6:

除了我们已经讨论的 pthread_mutex_lock()pthread_mutex_unlock(),一个互斥锁的完整生命周期还需要哪两个关键操作?如果不做,会对程序造成什么后果?

Gisli:

一个互斥锁的完整生命周期还需要:

  1. 初始化: 使用 pthread_mutex_init()。如果互斥锁是全局或静态变量 ,可以用宏 PTHREAD_MUTEX_INITIALIZER 静态初始化。
    • 后果: 如果互斥锁未初始化就直接使用 lock()unlock(),会造成未定义行为,通常导致程序崩溃。
  2. 销毁: 使用 pthread_mutex_destroy()
    • 后果: 互斥锁在初始化时可能分配了系统资源。如果不销毁(特别是在堆上动态分配的互斥锁),会导致资源泄漏。在某些操作系统上,未销毁的锁可能会阻止系统释放与其相关的内存或其他内核对象。

ChatGPT:

非常全面。初始化和销毁是保证资源正确管理的关键。


问题 7:

你提到死锁是使用互斥锁的主要问题之一。请描述一个经典的双锁死锁场景,并指出一个最简单、最有效的预防死锁的策略。

Gisli:

  • 死锁场景描述:

    1. 线程 A 调用 pthread_mutex_lock(lock_1),成功获取锁 1。
    2. CPU 切换,线程 B 调用 pthread_mutex_lock(lock_2),成功获取锁 2。
    3. 线程 A 试图调用 pthread_mutex_lock(lock_2),因锁 2 被 B 持有而阻塞
    4. 线程 B 试图调用 pthread_mutex_lock(lock_1),因锁 1 被 A 持有而阻塞
      此时,A 和 B 都在无限等待对方释放自己需要的资源,形成死锁。
  • 最有效预防策略: 统一加锁顺序(Lock Ordering)。

    • 要求程序中所有线程在尝试获取多个互斥锁时,必须以相同的顺序 (例如:总是先锁 lock_1,再锁 lock_2)获取它们。这样可以避免循环等待的条件,从而消除死锁。

ChatGPT:

统一加锁顺序是防止死锁最基础且实用的方法。


问题 8:

我们知道,一个进程如果结束了但其父进程没有调用 wait()waitpid() 回收其资源,它会变成僵尸进程 (Zombie Process)。那么,如果一个线程结束了,但没有线程调用 pthread_join()pthread_detach(),它会变成"僵尸线程"吗?为什么?

Gisli:

不会。线程的生命周期管理与进程不同:

  1. 线程的资源: 线程的绝大部分资源(如地址空间、文件描述符表)都是与同进程中的其他线程共享的,不需要回收。
  2. 需要回收的部分: 只有线程独有的少量资源(如线程栈、pthread_t 结构体中保存的内核对象)需要在线程终止后被释放。
  3. 未回收的后果: 如果一个线程结束,但既没有 join 也没有 detach,它将保持一个可汇合状态 (joinable state),其资源不会被释放。这种状态下的线程被称为可汇合但未被回收的线程,它不会像僵尸进程那样占用进程表项,但会占用内核内存资源,如果不加管理,仍可能导致资源耗尽。

学习实践过程遇到的问题与解决方式

问题 1:

为了避免内存泄漏,对于一个不再需要的线程,我们必须调用 pthread_join()pthread_detach() 中的一个。它们都能回收线程资源,但何时应该使用 pthread_join(),何时使用 pthread_detach()

简答:

  1. pthread_join() (汇合/等待):
    • 作用: 阻塞调用线程,使其等待目标线程终止。它还会接收目标线程的退出状态或返回值。
    • 适用场景: 当主线程需要等待子线程的结果 (返回值)或确认子线程已完成某个关键任务时使用。
    • 资源回收: 负责回收线程占用的所有系统资源(如线程栈、pthread_t 结构体内存)。
  2. pthread_detach() (分离):
    • 作用: 将线程设置为分离状态 。一旦线程终止,操作系统会自动回收其资源,无需其他线程调用 pthread_join()。它是非阻塞的。
    • 适用场景: 当创建的子线程是**"即发即弃"**的后台服务(Daemon Thread),且调用线程不关心其返回值或结束时间时使用。
    • 资源回收: 自动回收资源,避免因忘记 join 而导致的内核内存泄漏。

问题 2:

在使用多线程处理 I/O 密集型任务时(如并发下载文件),如果使用 fork() 进程来处理每个连接,效率会较低。如果使用 pthread 线程来处理,有哪些需要注意的关键系统限制?

GPT答:

虽然线程在数据共享和启动速度上优于进程,但在 I/O 密集型任务中,仍有关键的系统限制需要注意:

  1. 文件描述符限制 (File Descriptor Limits):

    • 无论是进程还是线程,单个进程能打开的文件描述符(File Descriptor, FD)数量是有限的(例如 Linux 默认 1024)。在多线程服务器中,每个客户端连接都会占用一个 FD,如果并发量过大,可能会达到这个进程级限制
    • 解决: 提高操作系统的 FD 限制(ulimit -n)或使用 I/O 多路复用(epoll/kqueue)来管理连接,而不是为每个连接都创建一个线程。
  2. 线程数限制 (Thread Limits):

    • 操作系统对单个进程可创建的线程数量有上限。创建过多的线程(例如数千个)会消耗大量内核内存和线程栈空间。
    • 解决: 采用线程池(Thread Pool)来重用线程,而不是无限创建新线程,以控制线程总数。
  3. 上下文切换开销 (Context Switching Overhead):

    • 尽管线程间的上下文切换比进程间切换轻量,但如果线程数远超 CPU 核心数,操作系统会在这些线程间频繁切换,这大量的上下文切换开销会抵消多线程带来的性能优势,导致整体性能下降,这种现象称为"线程爆炸"。

问题 3:

在多线程编程中,我们有时会遇到**不可重入函数(Non-reentrant Function)**的问题。什么是不可重入函数?它为什么在多线程环境下会引发线程安全问题?

简答:

  1. 不可重入函数定义: 不可重入函数是指在执行过程中,依赖于共享的、可变的 全局或静态数据,或者依赖于单例资源(如文件指针、I/O 缓冲区)的函数。
  2. 线程安全问题: 当一个线程正在执行一个不可重入函数时,如果被中断,另一个线程也调用了同一个函数,就会发生以下情况:
    • 数据损坏: 第二个线程可能会修改第一个线程正在使用的共享状态,导致两个线程的计算结果都被破坏。
    • 例子: C 标准库中的一些函数(如 strtok()asctime())在设计之初不是线程安全的,它们可能使用隐藏的静态缓冲区来存储结果。在多线程中同时调用它们,会导致缓冲区被覆盖,引发竞态条件。
  3. 解决方式: 应当使用可重入版本 的函数,例如使用 strtok_r() 代替 strtok(),这些可重入版本通常会要求调用者传递一个额外的参数来存放线程私有数据,避免使用全局状态。

本文链接

参考资料