【Linux】线程

【Linux】线程

文章目录

  • 【Linux】线程
    • 1、线程概念
    • 2、线程 & 进程
      • 2.1 资源分配 & 调度
      • 2.2 共享资源
      • 2.3 组合方式
    • 3、线程优缺点
      • 3.1 线程优点
      • 3.2 线程缺点
      • 3.3 线程异常
      • 3.4 线程使用
    • 4、线程控制
      • 4.1 线程创建
        • 4.1.1 错误处理机制
        • 4.1.2 线程ID
        • 4.1.3 线程标识类型
      • 4.2 线程终止
      • 4.3 线程等待
        • 4.3.1 线程等待意义
        • 4.3.2 pthread_join
      • 4.4 分离线程
    • 5、线程互斥
      • 5.1 例子
      • 5.2 重要概念
      • 5.3 互斥量
        • 5.3.1 加锁解锁
        • 5.3.2 互斥量的实现
    • 6、可重入与线程安全
    • 7、死锁
      • 7.1 死锁概念
      • 7.2 死锁条件
      • 7.3 避免死锁
    • 8、线程同步
      • 8.1条件变量
      • 8.2 同步概念 & 竞态条件
      • 8.3 条件变量函数
        • 8.3.1 初始化
        • 8.3.2 销毁
        • 8.3.3 等待
        • 8.3.4 唤醒等待
      • 8.4 条件变量使用规范
    • 9、生产消费者模型
      • 9.1 使用原因
      • 9.2 阻塞队列(BlokingQueue)
    • 10、POSIX信号量
      • 接口函数
    • 11、线程池
    • 12、单例模式
      • 12.1 设计模式
      • 12.2 单例模式
      • 12.3 线程安全性
    • 13、STL、智能指针与线程安全
      • 13.1 STL
      • 13.2 智能指针

1、线程概念

线程可以被理解为一个程序内部的执行路径,或者更确切地说,它是一个进程内部的控制序列。这个定义强调了线程作为进程的组成部分,用于实现程序的并发执行和多任务处理。

线程在进程内部运行,其实质是在共享的进程地址空间内执行。这种设计允许多个线程在同一个进程的上下文中并发执行,共享相同的内存空间和资源。这种共享使得线程之间的数据交换更加容易。

进程的虚拟地址空间是操作系统为每个进程提供的独立的内存地址范围。在这个地址空间内,进程可以存储代码、数据、堆栈以及其他需要的资源。这个概念在形成线程执行流时起着关键作用。

通过在同一个进程的虚拟地址空间内运行多个线程,线程可以共享进程的资源,如代码、数据、堆、文件句柄等。这样,不同的线程可以在同一个进程的上下文中并发执行,通过合理分配进程资源,操作系统可以在这些线程之间进行调度,从而实现更高效的多任务处理和并发执行。

使用进程虚拟地址空间的好处包括:

  1. 资源共享: 不同线程可以方便地共享进程的资源,避免了复制大量数据,从而提高了执行效率。

  2. 数据交换: 由于线程在同一进程内,它们可以直接访问相同的内存区域,这使得线程间的数据交换更加高效。

  3. 更低的开销: 创建和切换线程的开销通常比创建和切换进程的开销要小得多,因为线程共享大部分资源。

  4. 更快的通信: 由于线程共享相同的内存,线程之间的通信变得更加直接和快速。

2、线程 & 进程

2.1 资源分配 & 调度

从资源分配和调度的角度来区分线程与进程的概念,主要涉及到以下几点区别:

  1. 资源分配:

    • 进程:每个进程拥有独立的内存空间、代码、数据、文件描述符等资源。进程之间的资源互相隔离,一个进程的崩溃不会直接影响其他进程。
    • 线程:所有线程都共享同一个进程的资源,如内存空间、文件描述符等。线程之间可以更方便地进行数据交换,但需要注意资源的同步和互斥问题。
  2. 调度:

    • 进程:进程是操作系统进行调度的基本单位。不同进程之间的切换开销较大,因为需要切换内存空间、寄存器状态等。进程的调度相对较慢,适合于具有独立功能的任务。
    • 线程:线程是进程内部的调度单位。线程之间的切换开销较小,因为它们共享同一个进程的资源,只需切换寄存器状态。线程的调度较快,适合于需要高度并发和轻量级的任务。
  3. 并发性和并行性:

    • 进程:不同进程之间可以并行执行,每个进程在独立的内存空间中运行。进程之间的通信需要使用特定的机制,如进程间通信(IPC)。
    • 线程:线程之间可以并发执行,共享相同的内存空间。线程之间的通信相对容易,但需要考虑同步问题。
  4. 创建与销毁开销:

    • 进程:创建和销毁进程的开销相对较大,涉及到资源分配、内存空间初始化等操作。
    • 线程:创建和销毁线程的开销相对较小,因为线程共享大部分资源,创建线程只需要分配栈空间和一些必要的控制结构。

2.2 共享资源

每个线程共享进程的大部分数据,但也有一些线程特有的数据。

线程特有数据:

  1. 线程ID: 每个线程都有一个独特的线程标识符,用于在进程中唯一标识线程。

  2. 一组寄存器: 每个线程拥有自己的寄存器集,用于存储线程特定的变量和执行状态。这使得线程之间可以独立地进行计算。

  3. 栈: 每个线程拥有自己的栈空间,用于存储局部变量、函数调用信息和返回地址。这确保了线程在执行时具有独立的调用栈。

  4. errno: errno 是一个在线程局部存储中的变量,用于记录与错误相关的信息。每个线程都有自己的 errno 值,使得错误处理在不同线程间互不影响。

  5. 信号屏蔽字: 信号屏蔽字是一个位掩码,用于控制线程对于特定信号的响应。不同线程可以有不同的信号屏蔽字,以决定是否屏蔽某些信号。

共享的数据:

  1. 进程数据段: 线程共享进程的数据段,包括全局变量和静态变量。这使得线程可以共享这些数据,但也需要注意同步问题。

  2. 进程代码段: 所有线程共享相同的代码段,也就是程序的指令。

  3. 堆: 线程共享进程的堆空间,用于动态内存分配。

  4. 文件描述符: 所有线程共享进程的打开文件描述符,这意味着一个线程打开的文件在其他线程中也是可见的。

  5. 调度优先级: 调度优先级是一个用于指导线程调度的值,影响操作系统在不同线程间进行切换的策略。

2.3 组合方式

不同的进程和线程组合方式:

  1. 单线程进程:

    • 这指的是一个进程内只有一个执行线程。该线程负责执行进程中的所有任务。
    • 单线程进程适用于某些简单任务,不需要并发执行或多任务处理的场景。
    • 缺点是当该线程阻塞(比如等待用户输入或等待外部资源)时,整个进程会被阻塞,影响程序的响应性。
  2. 单进程多线程:

    • 这指的是一个进程内有多个执行线程,共享进程的资源,如内存、文件描述符等。
    • 多线程在单进程内并发执行,可以提高程序的响应性和并发处理能力。
    • 需要注意线程之间的同步和资源竞争问题。
  3. 多线程进程:

    • 这指的是一个进程内有多个执行线程,每个线程独立执行不同的任务。
    • 每个线程执行不同的工作,共享进程的资源,可以更有效地实现多任务处理。
    • 需要注意线程之间的同步和协调,以及合理分配进程资源。

单线程进程适用于简单任务,单进程多线程适用于需要并发处理但任务相对简单的场景,多线程进程适用于需要多任务处理和高并发的情况。

3、线程优缺点

3.1 线程优点

  1. 创建新线程代价低: 相对于创建新进程,创建新线程的开销要小得多。这是因为线程共享进程的资源,创建新线程只需要分配栈空间和一些控制结构。

  2. 线程切换开销小: 由于线程共享进程的资源,线程之间的切换所需的工作较少。这导致线程切换的开销相对较小,有助于提高程序的响应性能。

  3. 较少资源占用: 线程比进程占用更少的资源,因为线程共享大部分资源,如内存、文件描述符等。这使得系统可以容纳更多的线程,而不会出现资源枯竭问题。

  4. 多处理器利用率高: 线程能够充分利用多处理器系统的可并行性,从而提高并发执行的效率和性能。

  5. I/O操作重叠: 在等待慢速I/O操作完成的同时,程序可以继续执行其他的计算任务,从而充分利用 CPU 时间,提高程序的整体性能。

  6. 计算密集型应用分解: 对于计算密集型应用,可以将计算任务分解为多个线程,以便在多处理器系统上并行执行,提高计算速度。

  7. I/O密集型应用优化: 对于I/O密集型应用,可以使用多线程将不同的I/O操作重叠,从而减少等待时间,提高系统的整体吞吐量。

3.2 线程缺点

  1. 性能损失: 当有大量计算密集型线程并且处理器数量有限时,线程的切换和同步开销可能会导致性能下降。过多的线程会增加额外的调度开销,降低整体效率。

  2. 健壮性降低: 多线程编程需要更全面的考虑,因为线程之间的交互可能会导致难以预测的问题。细微的时间分配偏差或共享变量的问题可能会影响程序的健壮性。

  3. 缺乏访问控制: 在多线程环境中,一个线程对某些资源或变量的访问可能会影响整个进程,因为线程共享进程的资源。这可能导致一些无意的干扰。

  4. 编程难度提高: 编写和调试多线程程序比单线程程序更加复杂。线程之间的竞态条件、死锁等问题可能不易发现和解决,从而增加了编程难度。

3.3 线程异常

在多线程编程中,单个线程出现异常(如除零、野指针引用等)可能会导致整个进程崩溃。这是因为线程是进程的执行分支,所有线程共享同一个进程的资源,包括内存空间和文件描述符等。

当一个线程出现致命错误或异常时,操作系统可能会触发信号机制,发送一个异常信号给整个进程。这会导致整个进程被终止,从而影响进程内的所有线程。这是为了确保整个进程的稳定性,因为一个线程的崩溃可能会影响共享资源和整体程序的执行。

这也强调了多线程编程的挑战之一:需要谨慎处理异常,确保线程在出现问题时能够正确地终止,并采取适当的措施来避免进程的崩溃。同时,合理的错误处理和异常处理机制可以帮助提高程序的健壮性和可靠性。

3.4 线程使用

多线程在不同类型的应用中都可以提供性能和用户体验上的优势:

CPU密集型程序:
在CPU密集型程序中,大部分时间都花费在执行计算操作上。使用多线程可以将计算任务分解为多个线程,并在多处理器系统上并行执行,从而充分利用多核处理器的性能。这可以显著提高程序的执行效率,加速计算密集型任务的完成。

IO密集型程序:
在IO密集型程序中,大部分时间都花费在等待IO操作完成上,如文件读写、网络请求等。在这种情况下,使用多线程可以提高程序的并发性,允许程序在等待IO操作时,切换到其他线程执行不涉及IO的任务,从而充分利用CPU时间。这可以提高用户体验,使程序在等待IO操作时也能保持响应性,就像您提到的一边写代码一边下载开发工具的情况。

在设计多线程应用时需要考虑资源共享、同步问题和竞态条件等。

合理分配线程,避免线程间的竞争和死锁,以及使用适当的同步机制都是确保多线程程序高效稳定运行的关键。

4、线程控制

4.1 线程创建

POSIX线程库

与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以“pthread_”打头的 要使用这些函数库,要通过引入头文 链接这些线程函数库时要使用编译器命令的“-lpthread”选项

POSIX 线程库(pthread)创建新线程的函数 pthread_create

  1. pthread_create 函数用于在程序中创建一个新的线程。这个函数将新线程创建在调用它的线程的上下文中。

  2. 参数说明:

    • thread:一个指向 pthread_t 类型的变量的指针,用于存储新线程的标识符。
    • attr:一个指向 pthread_attr_t 类型的变量的指针,用于设置线程的属性。如果传递 NULL,将使用默认的线程属性。
    • start_routine:是一个函数指针,指向新线程启动后要执行的函数。该函数应该具有 void* 类型的参数,并返回 void* 类型的结果。
    • arg:传递给 start_routine 函数的参数。
  3. 返回值:函数返回一个整数值,如果创建线程成功,它将返回 0。如果出现错误,返回一个非零的错误码,您可以通过这个错误码来识别出问题的原因。

4.1.1 错误处理机制

POSIX 线程库 (pthreads) 的错误处理机制:

  1. 错误返回值和全局变量 errno:

    • 在传统的 POSIX 函数中,成功通常返回 0,而失败则返回 -1,并设置全局变量 errno 以指示错误的类型。
    • 然而,pthreads 函数在出错时不会设置全局变量 errno。而是通过函数的返回值来传递错误信息。如果函数返回非 0 的值,就表示出现了错误。
  2. 返回值和错误代码:

    • 对于 pthreads 函数,返回值非零通常表示出现了错误,而返回 0 表示成功。
    • 而不同的错误代码对应于不同的错误类型。您可以根据返回值来判定发生的错误。
  3. 线程内的 errno 变量:

    • 尽管 pthreads 函数不设置全局的 errno 变量,但它们提供了线程内的 errno 变量,用于支持其他可能使用 errno 的代码。
    • 您可以使用线程内的 errno 变量来获取错误信息,但从性能和开销角度来看,读取函数的返回值通常更为高效,因为不需要频繁访问线程的特定数据。

总之,pthreads 函数通过返回值来传递错误信息,而不是使用传统的全局变量 errno。使用返回值来判定错误通常是更好的选择,尤其在性能方面。

4.1.2 线程ID

线程ID的两个不同概念:操作系统调度器的线程ID和NPTL线程库中的线程ID:

  1. 操作系统调度器的线程ID:

    • 在操作系统层面,每个线程都有一个唯一的标识符,称为线程ID。这个线程ID是操作系统调度器(内核)用来识别和管理不同线程的。
    • 操作系统使用线程ID来进行线程的创建、调度和切换。不同操作系统可能在线程ID的分配规则和表示方式上有所不同。
  2. NPTL线程库的线程ID:

    • NPTL(Native POSIX Thread Library)是Linux上的一个线程库,用于在用户空间实现多线程功能。它为每个线程分配了一个独特的线程ID,该ID用于在用户空间管理线程。
    • 当您使用 pthread_create 函数创建一个新线程时,它会将新线程的ID存储在您提供的指针变量中。这个ID是线程库NPTL使用的线程ID,用于在用户空间管理线程。
  3. pthread_self 函数:

    • pthread_self 函数是NPTL线程库提供的一个函数,用于获取当前线程的线程ID。这个线程ID是线程库内部使用的标识符,可以用于操作线程。

总结来说,操作系统调度器使用操作系统级别的线程ID来管理线程,而线程库(如NPTL)使用其自己的线程ID来在用户空间管理线程。pthread_create 创建线程后,会提供一个用于线程库的线程ID,并且 pthread_self 函数可以获取当前线程在线程库中的ID。

4.1.3 线程标识类型

pthread_t 是一个线程标识类型,但其具体实现可能因操作系统和线程库而异。在Linux NPTL(Native POSIX Thread Library)等实现中,pthread_t 线程标识本质上是一个指向线程控制块(Thread Control Block,TCB)的指针。

线程控制块(TCB)是线程库(NPTL)内部维护的数据结构,用于存储关于线程的信息,如状态、栈信息、优先级等。pthread_t 在这种情况下是一个指向线程控制块的指针,用于标识和管理线程。

由于不同的线程库和操作系统实现,pthread_t 的具体类型和含义可能会有所不同。对于不同的操作系统和线程库,pthread_t 可能被实现为不同的数据类型,如整数、指针等。

4.2 线程终止

多线程程序中单独终止线程而不影响整个进程:

  1. 从线程函数 return:

    • 在线程函数内部,通过从函数返回的方式来终止线程。这对于非主线程是适用的,因为主线程返回相当于调用 exit,会终止整个进程。
    • 注意,线程函数内的 return 不会像 exit 那样执行进程终止处理,它只会终止当前线程。
  2. 调用 pthread_exit:

    • 线程可以调用 pthread_exit 函数来终止自身。这将导致调用线程的退出,但不会影响其他线程或整个进程。
  3. 调用 pthread_cancel:

    • 一个线程可以调用 pthread_cancel 函数来请求终止同一进程中的另一个线程。但是,被取消的线程需要在适当的位置检查取消请求,并在需要时进行清理,以确保终止的安全性。

这些方法提供了灵活的线程终止方式,允许需要时选择适当的方法来终止线程,同时保持其他线程和整个进程的正常运行。需要注意的是,确保在终止线程时进行资源的正确释放和清理,以避免内存泄漏或其他问题。

pthread_exit 函数:

  • pthread_exit 函数用于终止调用它的线程。它将线程的执行从当前位置开始终止,不会执行后续代码,也不会回到线程的调用者。这类似于函数的 return,但是 pthread_exit 是在线程中使用的。
  • value_ptr 参数允许线程指定一个退出状态值。这个状态值可以被其他线程通过 pthread_join 函数获取。需要注意的是,value_ptr 参数不应该指向局部变量,因为线程终止后局部变量可能会被释放,导致无法正确获取退出状态。

返回值:

  • pthread_exit 函数本身没有返回值,它仅用于终止线程。线程结束时,无法返回到它的调用者(即自身)。

使用 pthread_exit 可以在线程执行的任意地方终止线程,而不会影响其他线程或整个进程。需要确保在线程终止前进行资源的清理和释放,以避免内存泄漏或其他问题。同时,可以使用 pthread_join 来等待终止的线程,并获取它的退出状态值。

pthread_cancel 函数:

  • pthread_cancel 函数用于请求取消一个指定的线程。被请求取消的线程将在适当的时候中断执行,但这不是立即发生的,因为线程需要在取消点(cancellation point)才能被取消。

参数:

  • thread:要取消的线程的线程ID。

返回值:

  • pthread_cancel 函数的返回值表示成功或失败。如果成功,它将返回 0,否则返回一个错误码,表示请求取消线程失败。

需要注意的是,pthread_cancel 函数只是发送一个取消请求给指定的线程,但被取消的线程需要在适当的时间点响应这个请求。在线程内部,您可以通过设置取消状态和检查取消状态来实现合适的终止逻辑。

当使用 pthread_cancel 时,需要仔细考虑线程的设计和取消点的选择,以确保线程能够在安全的状态下被取消。

当线程使用 pthread_exitreturn 终止时,返回的指针所指向的内存必须是在全局作用域中分配的,或者是使用动态内存分配函数(如 malloc)在堆上分配的。避免在线程函数的栈上分配内存,因为线程函数退出后栈上的数据会被释放,导致指针指向无效内存。

这是因为在线程函数退出后,返回的指针将不再指向有效的内存,其他线程如果尝试访问这个指针指向的内存,可能会导致未定义的行为,如访问非法内存区域、内存损坏等问题。

如果在线程函数中需要返回数据给其他线程或用于线程间通信,最好是使用全局变量或者动态内存分配,以确保返回的数据在线程函数退出后依然有效。

4.3 线程等待

4.3.1 线程等待意义

线程等待是为了确保线程的正确执行顺序和资源的正确释放。尽管已经退出的线程的空间仍然存在于进程的地址空间内,但在线程结束后,其资源可能需要被适当地清理和释放,以避免资源泄漏或其他问题。以下是为什么需要线程等待的一些原因:

  1. 资源管理: 如果一个线程分配了一些资源(如内存、文件句柄等),在线程退出后,这些资源可能需要被释放。等待线程结束可以确保在继续执行之前,资源得到适当的清理和释放。

  2. 顺序控制: 有时候,程序的正确执行需要保证某个线程在另一个线程完成后再执行。通过等待线程可以确保正确的执行顺序。

  3. 获取线程的返回值: 如果线程执行了一些计算并返回了结果,其他线程可能需要等待以获取这些结果。

  4. 避免竞态条件: 在多线程环境中,等待线程的结束可以避免某些竞态条件的问题,例如在一个线程修改了某些共享资源后立即退出,而其他线程还在使用这些资源。

关于地址空间复用问题,是因为在多线程环境下,每个线程都共享同一个进程的地址空间,所以不会为每个线程都分配独立的地址空间。新线程可以访问之前已经退出的线程的地址空间,但线程等待的主要目的不在于复用地址空间,而是确保线程执行的正确性和资源的合理管理。

4.3.2 pthread_join

  • pthread_join 函数用于等待指定的线程结束。当调用线程调用此函数来等待一个线程,它会一直阻塞,直到指定的线程终止。

参数:

  • thread:要等待的线程的线程ID。
  • value_ptr:一个指向指针的指针,用于存储被等待线程的退出状态值。

返回值:

  • pthread_join 函数的返回值表示成功或失败。如果成功,它将返回 0,否则返回一个错误码。

pthread_join 的主要作用是允许线程在另一个线程终止之后继续执行。它也用于获取被等待线程的退出状态值。需要注意的是,如果被等待的线程已经终

不同方式终止线程以及通过 pthread_join 获取终止状态的情况:

  1. 线程通过 return 返回:

    • 如果线程通过从线程函数返回来终止,value_ptr 所指向的单元里将存放线程函数的返回值。
  2. 线程被其他线程使用 pthread_cancel 终止:

    • 如果线程被其他线程使用 pthread_cancel 异常终止,value_ptr 所指向的单元里将存放常数 PTHREAD_CANCELED
  3. 线程自己调用 pthread_exit 终止:

    • 如果线程是通过调用 pthread_exit 自己终止的,value_ptr 所指向的单元将存放传给 pthread_exit 的参数。
  4. 不关心线程的终止状态:

    • 如果对线程的终止状态不感兴趣,可以将 NULL 传递给 value_ptr 参数,这样 pthread_join 将不会返回终止状态。

4.4 分离线程

在使用 pthreads 库创建线程时,默认情况下线程是可被等待的(joinable),这意味着当线程退出时,需要使用 pthread_join 函数来等待并获取线程的退出状态,以释放线程占用的资源。然而,如果不关心线程的退出状态或者不想等待线程,可以将线程设置为分离状态,使其在退出后自动释放资源。

分离线程的步骤:

  1. 在线程创建后,使用 pthread_detach 函数将线程设置为分离状态。
  2. 一旦线程被设置为分离状态,就不需要再使用 pthread_join 来等待线程了。

下面是一个简单的示例:

#include 

void *thread_function(void *arg) {
    // 线程的工作内容
    // ...
    return NULL;
}

int main() {
    pthread_t thread_id;
    pthread_create(&thread_id, NULL, thread_function, NULL);

    // 将线程设置为分离状态
    pthread_detach(thread_id);

    // 主线程可以继续执行,而不需要调用 pthread_join
    // ...

    return 0;
}

需要注意的是,分离线程后,无法使用 pthread_join 来等待线程,也无法获取线程的退出状态。分离线程适用于不关心线程的退出状态或者不想等待线程的情况,但需要注意确保线程的资源不会造成泄漏。分离线程后,线程一旦退出,其资源会自动被系统释放。

将线程设置为分离状态相关函数:

  1. pthread_detach(pthread_t thread)

    • pthread_detach 函数用于将指定线程设置为分离状态,从而使得线程在退出时自动释放资源,而不需要使用 pthread_join 来等待线程。
    • 参数 thread 是要设置为分离状态的线程的线程ID。
  2. pthread_detach(pthread_self())

    • pthread_self() 函数用于获取当前线程的线程ID。
    • pthread_self() 返回的线程ID作为参数传递给 pthread_detach 函数,就可以将当前线程设置为分离状态。

示例代码:

#include 

void *thread_function(void *arg) {
    // 线程的工作内容
    // ...
    return NULL;
}

int main() {
    pthread_t thread_id;
    pthread_create(&thread_id, NULL, thread_function, NULL);

    // 将指定线程设置为分离状态
    pthread_detach(thread_id);

    // 或者将当前线程设置为分离状态
    pthread_detach(pthread_self());

    // 主线程可以继续执行,而不需要调用 pthread_join
    // ...

    return 0;
}

joinable 和分离状态确实是互斥的,一个线程不能同时具有这两种状态。

  • joinable 状态:这是线程的默认状态。在这种状态下,线程被创建后,可以使用 pthread_join 来等待线程结束,以获取其退出状态。这使您可以控制线程的执行顺序,并获取线程的返回值。

  • 分离状态:在线程被设置为分离状态后,它不再与主线程同步。主线程无法使用 pthread_join 等待分离线程结束,也无法获取分离线程的返回值。分离线程在退出时会自动释放其资源。

调用 pthread_detach 函数将线程设置为分离状态时,不能再调用 pthread_join 来等待线程。而如果想等待线程的结束并获取其返回值,那么线程应该保持 joinable 状态,但在这种情况下,就需要显式调用 pthread_join 来等待线程。

5、线程互斥

5.1 例子

假设有一个银行账户,多个用户线程可以同时存款和取款。这个银行账户是共享资源,因为多个线程都可以同时对它进行操作。然而,如果不加以控制,就会发生竞争条件,导致账户余额计算不正确。

现在我们用一个通俗易懂的例子来说明线程互斥的重要性:

情景:
有两个用户线程 A 和 B,同时想要向同一个银行账户存款。

问题:
如果不使用线程互斥,可能会发生以下情况:

  1. 线程 A 读取账户余额为 $100。
  2. 线程 B 读取账户余额为 $100。
  3. 线程 A 增加 $50 到账户,新余额为 $150。
  4. 线程 B 也增加 $50 到账户,新余额也应该为 $150,但由于它在读取余额之前读取的余额是 $100,所以它认为新余额是 $100 + $50 = $150。
  5. 结果是,实际余额为 $150,但线程 B 认为余额是 $200。

这就是典型的竞争条件,由于没有控制好线程对共享资源的访问,导致数据不一致。

使用线程互斥的解决方法:
我们可以使用互斥锁来确保同时只有一个线程能够对账户进行操作。当一个线程进入存款操作(临界区)时,它会尝试获取互斥锁。如果互斥锁已经被其他线程持有,那么它会等待。一旦线程完成存款操作并释放了互斥锁,下一个线程才能进入临界区执行存款操作。

这样,通过使用互斥锁,我们确保了在任何时刻只有一个线程能够操作账户,避免了竞争条件,从而保证了账户余额的正确性。

总之,线程互斥的核心思想就是在临界区操作前使用互斥锁来保证同一时刻只有一个线程能够进入,从而避免了数据不一致的问题。

5.2 重要概念

在上述例子中,涉及了线程互斥中的以下重要概念:

  1. 共享资源: 银行账户是共享资源,多个线程都可以同时对它进行存款和取款操作。

  2. 临界区: 存款和取款操作是临界区,因为它们涉及到对共享资源的修改,可能会导致竞争条件。

  3. 互斥锁(mutex): 互斥锁用于确保同一时刻只有一个线程能够进入临界区操作。它防止多个线程同时修改共享资源,从而避免竞争条件和数据不一致。

mutex简单理解就是一个0/1的计数器,用于标记资源访问状态:

  • 0表示已经有执行流加锁成功,资源处于不可访问,
  • 1表示未加锁,资源可访问。
  1. 竞争条件: 在没有互斥保护的情况下,多个线程同时读写共享资源,可能导致数据不一致和不正确的结果。

在这个例子中,使用互斥锁来控制临界区操作,确保了同一时刻只有一个线程能够访问共享资源(银行账户)。这防止了竞争条件和数据不一致的问题,确保了线程安全性。

5.3 互斥量

在上述例子中,互斥量是一种同步机制,它用于确保线程安全地访问共享资源(银行账户)。互斥量提供了一种方式,允许只有一个线程在同一时刻持有该互斥量,从而进入临界区(存款或取款操作),其他线程必须等待互斥量被释放后才能进入临界区。

具体来说,对于每个线程访问共享资源的临界区,我们可以创建一个互斥量。当一个线程想要进入临界区时,它会尝试获取互斥量(加锁),如果互斥量已被其他线程持有,则线程将等待。一旦线程完成临界区操作,它会释放互斥量(解锁),使得其他线程能够获得该互斥量,从而进入临界区。

在这个例子中,互斥量就是用来保护银行账户作为共享资源的关键工具。它确保了同一时刻只有一个线程能够进入存款或取款操作,避免了竞争条件,保障了账户余额的正确性。

初始化互斥量是在使用互斥量之前的一项重要步骤,以确保互斥量的正确操作。在标准的 POSIX 线程库中,有几种初始化互斥量的方法:

  1. 静态初始化: 使用 PTHREAD_MUTEX_INITIALIZER 宏可以在编译时静态地初始化一个互斥量。这种初始化方式适用于全局变量和静态变量,例如:

    pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
    
  2. 动态初始化: 使用 pthread_mutex_init 函数可以在运行时动态地初始化互斥量。这个函数允许您设置互斥量的属性,例如锁的类型(普通锁、递归锁等)。

    pthread_mutex_t mutex;
    pthread_mutex_init(&mutex, NULL); // 或者传递一个 pthread_mutexattr_t 对象来设置属性
    
  3. 销毁互斥量: 在使用完互斥量后,应该使用 pthread_mutex_destroy 函数来销毁互斥量,释放相关资源。

    pthread_mutex_destroy(&mutex);
    

在使用 PTHREAD_MUTEX_INITIALIZER 宏进行静态初始化的互斥量上,不需要调用 pthread_mutex_destroy 来销毁它们。这是因为静态初始化的互斥量在编译时就被初始化为适当的值,不需要在程序运行时进行初始化或销毁。

另外,确实应该注意以下几点:

  1. 不要销毁已加锁的互斥量: 如果一个互斥量已经被加锁,那么在其解锁之前不应该销毁它。销毁已加锁的互斥量可能导致未定义的行为。

  2. 销毁的互斥量不再使用: 一旦互斥量被销毁,后续不应该再尝试对它进行加锁操作。这可能会导致不可预测的行为。

5.3.1 加锁解锁

加锁(Locking):

加锁是指线程试图获取互斥锁以进入临界区。如果互斥锁当前没有被其他线程持有,线程将成功获取互斥锁,然后进入临界区执行操作。如果互斥锁已经被其他线程持有,线程将被阻塞,直到互斥锁被释放。

在 POSIX 线程库中,使用 pthread_mutex_lock 函数来加锁互斥量:

pthread_mutex_t mutex;
pthread_mutex_init(&mutex, NULL); // 初始化互斥量

pthread_mutex_lock(&mutex); // 加锁操作
// 执行临界区操作
pthread_mutex_unlock(&mutex); // 解锁操作

解锁(Unlocking):

解锁是指线程释放互斥锁,允许其他线程获取该锁并进入临界区。解锁操作通常发生在临界区的操作完成后。

在 POSIX 线程库中,使用 pthread_mutex_unlock 函数来解锁互斥量:

pthread_mutex_unlock(&mutex); // 解锁操作

需要注意的是,加锁和解锁操作必须成对出现。即使在出现异常或错误的情况下,也必须确保在解锁前加锁的互斥量。

调用 pthread_mutex_lock 时会出现不同的情况,根据互斥量的状态和其他线程的操作,可能会发生以下情况:

  1. 互斥量处于未锁状态: 如果互斥量当前没有被其他线程锁定,调用 pthread_mutex_lock 将成功地锁定互斥量,并且函数会立即返回。

  2. 互斥量已经被其他线程锁定: 如果另一个线程已经锁定了互斥量,那么当前线程的调用将会被阻塞,直到互斥量被解锁。这确保了同一时刻只有一个线程能够进入临界区。

  3. 其他线程同时申请互斥量: 如果有多个线程几乎同时申请同一个互斥量,只有一个线程会成功地获得互斥锁,其他线程将被阻塞。这种情况下,操作系统的调度策略会决定哪个线程获得锁的机会。

在多线程编程中,合理地使用互斥量来避免竞争条件和数据不一致是至关重要的。通过使用互斥锁,可以确保在适当的时候只有一个线程能够进入临界区,从而维护共享资源的正确性。

5.3.2 互斥量的实现

互斥量的底层实现和原子操作的概念是确保多线程环境下数据一致性和线程安全的关键部分:

数据一致性问题:
在多线程环境中,多个线程可能同时访问和修改共享的数据,这可能导致数据一致性问题,如竞争条件和数据不一致。为了避免这些问题,需要使用同步机制来保护共享资源的访问。

原子操作:
原子操作是指在执行过程中不会被中断的操作,它要么完全执行,要么不执行,不存在中间状态。原子操作是确保数据一致性的基础,因为它们可以在不需要额外同步的情况下保证数据的正确性。

互斥量的实现:
互斥量的实现涉及到底层的硬件和指令,例如您提到的 swap 或 exchange 指令。这些指令确保在一个处理器执行的原子操作不会被中断,从而保证多线程环境下的数据一致性。当一个线程想要进入临界区时,它会尝试使用互斥锁。互斥锁的实现使用原子操作来确保只有一个线程能够成功地获取锁,其他线程将被阻塞。

在现代计算机体系结构中,确保原子性和数据一致性是操作系统和硬件的重要部分。操作系统提供了对原子操作的支持,同时硬件体系结构也提供了相关的指令,如 CAS(Compare and Swap)指令,用于实现原子操作。这些机制和指令共同支持了多线程环境下的同步和互斥。

综上所述,互斥量的实现依赖于底层的原子操作和硬件支持,这些机制确保了在多线程环境中对共享资源的访问是安全和同步的。

6、可重入与线程安全

可重入性与线程安全性之间存在密切联系,但两者又有一些区别:

可重入性(Reentrancy):
可重入性是指一个函数能够被多个线程同时调用,而不会导致不正确的结果。如果一个函数是可重入的,那么它可以安全地在多个线程之间共享,因为它不会修改全局数据,也不会依赖于全局状态。可重入函数的特点是,在任意时刻,函数的内部状态只由它的参数和局部变量所决定,不依赖于外部的状态。

线程安全性(Thread Safety):
线程安全性是指多个线程能够同时调用函数,而不会破坏共享资源或导致不一致的结果。线程安全函数可以被多个线程并发地调用,而不需要额外的同步措施,如互斥锁。线程安全函数可以修改全局数据,但它会使用适当的同步机制来确保共享资源的正确性。

联系和区别:

  • 一个可重入函数一定是线程安全的,因为它的内部状态只由局部变量和参数决定,不依赖于全局状态。
  • 线程安全函数不一定是可重入的,因为线程安全函数可能会修改全局数据,需要使用同步机制来保护共享资源。这可能导致不同线程在调用过程中相互干扰,从而使函数不是可重入的。
  • 如果一个函数有全局变量且没有适当的同步机制,它既不是线程安全的也不是可重入的。因为多个线程同时调用可能导致数据竞争和不一致。

死锁问题:
在可重入函数中使用锁可能导致死锁。这是一个重要的注意点。如果可重入函数在一个线程中获得了锁,而另一个线程也尝试调用同一个函数,会导致死锁,因为锁已经被占用。因此,在设计可重入函数时,必须小心处理锁的问题,以避免死锁的情况。

总之,可重入性和线程安全性是多线程编程中的关键概念,确保函数在多线程环境中的正确性和安全性。

7、死锁

7.1 死锁概念

死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。

可以想象两个人在公共厕所门口陷入了一个有趣的情境:

场景: 假设有两个单独的卫生间,每个卫生间只能同时供一个人使用。现在,有两个人,小明和小红,分别站在这两个卫生间的门口。每个人都想使用两个卫生间,但是只有一个人可以进入一个卫生间。

四个必要条件在这个例子中的解释:

  1. 互斥条件: 每个卫生间一次只能供一个人使用,无法同时供多人使用。
  2. 请求与保持条件: 小明已经站在一个卫生间的门口,希望能进去。小红也在另一个卫生间的门口,同样希望能进去。他们都在等待自己要使用的卫生间。
  3. 不剥夺条件: 一旦一个人进入了卫生间,他们在使用完之前无法被其他人剥夺。
  4. 循环等待条件: 小明在等待进入第一个卫生间,同时小红在等待进入第二个卫生间,形成了一个循环等待的情况。

可能的死锁情况:

  • 如果小明进入了第一个卫生间,但是小红无法进入第二个卫生间,因为它被小明占用了。
  • 同时,小红进入了第二个卫生间,但是小明无法进入第一个卫生间,因为它被小红占用了。

在这种情况下,两个人都无法继续前进,因为他们互相占用了对方所需的资源,即卫生间。这就是一个类似于死锁的情景,因为两个人都无法继续执行他们的任务。

这个例子能够帮助理解死锁的概念,以及造成死锁的四个必要条件是如何相互作用的。在编程中,类似的情况可能发生在多个线程竞争有限的资源时,如果不恰当地管理这些资源,可能会导致死锁。

7.2 死锁条件

造成死锁的必要条件,也被称为死锁的“四个必要条件”。这些条件在一起可能导致系统陷入无法前进的状态,因为每个线程都在等待某个资源被释放,而这个资源却由其他线程持有。

  1. 互斥条件(Mutual Exclusion): 每个资源同时只能被一个线程持有,如果一个线程已经获得了某个资源,其他线程就无法再获取该资源。

  2. 请求与保持条件(Hold and Wait): 至少一个线程已经持有了一个资源,并且在等待获取其他线程持有的资源。这就意味着即使一个线程没有得到所有需要的资源,它也可以持有一些资源,从而阻止其他线程获取这些资源。

  3. 不剥夺条件(No Preemption): 已经被一个线程持有的资源不能被强制性地从持有它的线程中剥夺,只能由持有者显式地释放。

  4. 循环等待条件(Circular Wait): 一组线程相互之间形成一个循环,每个线程都在等待下一个线程所持有的资源,形成一个资源的循环等待链。

当这四个条件同时满足时,系统可能进入死锁状态。

7.3 避免死锁

避免死锁的常见策略:

  1. 破坏死锁的四个必要条件: 死锁的四个必要条件是互斥、请求与保持、不剥夺和循环等待。为了避免死锁,可以尝试破坏这些条件之一或多个。例如,通过确保资源分配是不可剥夺的,或者通过限制资源的循环等待关系,可以减少死锁的可能性。

  2. 加锁顺序一致: 保持相同的锁获取顺序可以降低死锁的风险。如果所有线程都按照相同的顺序获取锁,那么即使它们发生等待,也不会形成循环等待条件。确保所有线程以相同的顺序获取锁是一种简单而有效的死锁避免策略。

  3. 避免锁未释放的场景: 死锁可能发生在一个线程持有一个锁时等待另一个锁,而另一个线程持有另一个锁时等待第一个锁。为了避免这种情况,可以使用超时机制或者尝试获取锁而不是一直等待。如果获取锁的尝试失败,线程可以释放已经获取的锁,等待一段时间后重新尝试获取。

  4. 资源一次性分配: 死锁的一个常见原因是资源被持续地分配和释放,从而导致线程之间的循环等待。一种避免策略是一次性分配所有需要的资源,从而消除了循环等待的可能性。这种方法在某些情况下可能会降低资源利用率,但可以有效地避免死锁。

综上所述,死锁是多线程环境中一个重要的问题,可以通过破坏死锁的必要条件、维护一致的锁获取顺序、避免锁未释放的场景以及一次性分配资源等方式来减少死锁的风险。不同的应用场景可能需要采用不同的策略来确保线程安全性和避免死锁。

8、线程同步

8.1条件变量

条件变量是在多线程编程中用于线程之间同步的一种机制。它通常用于线程等待某个特定的条件满足时才继续执行。

条件变量的主要特点和用法:

  • 等待和通知: 条件变量允许一个线程等待某个条件被满足,同时允许另一个线程在条件满足时发出通知,从而唤醒等待的线程。
  • 与互斥锁一起使用: 条件变量通常需要和互斥锁一起使用,以确保在等待和通知过程中的线程安全性。等待条件时,线程会释放互斥锁,以便其他线程可以修改共享资源;当条件满足时,线程会重新获得互斥锁,继续执行。
  • 避免忙等待: 使用条件变量可以避免忙等待,即不断地轮询检查条件是否满足。线程可以在条件变量上等待,直到被通知条件满足,这样可以节省系统资源。

在队列示例中,一个线程可以在队列为空时等待条件变量,而另一个线程在向队列中添加元素后,通过发出条件变量的通知,唤醒等待的线程。这种方式可以有效地实现线程之间的同步,避免忙等待,从而提高程序的性能和效率。

总之,条件变量是多线程编程中一种重要的同步机制,用于实现线程之间的等待和通知,以及避免忙等待的情况。

8.2 同步概念 & 竞态条件

同步(Synchronization): 同步指的是多个线程之间协调和协同工作,以达到正确的执行顺序和数据状态。在多线程环境中,由于线程的并发执行,可能会导致数据的不一致或意外行为。通过同步机制,可以保证线程按照特定的顺序访问共享资源,从而避免数据竞争和不确定性。同步的目标是保证数据的一致性和正确性。

竞态条件(Race Condition): 竞态条件指的是多个线程竞争共享资源或变量的情况下,由于它们的执行时序不确定,可能导致程序的行为不符合预期。竞态条件通常发生在多个线程对共享资源进行读写操作时,而且至少有一个线程在写操作。这种情况下,最终结果可能取决于线程的执行时序,而不是编程人员的意图。竞态条件可能导致数据不一致、错误的计算结果、崩溃等问题。

临界区(Critical Section): 临界区是指一个程序片段,在其中一个线程访问共享资源或变量。为了避免竞态条件,可以使用互斥锁等同步机制将临界区的访问串行化,这样只有一个线程能够在任何时刻进入临界区执行操作,从而保证数据一致性。

综上所述,同步是为了保证多线程环境下数据正确性的机制,而竞态条件是在缺乏适当同步时可能出现的问题。临界区是一个关键概念,用于描述需要同步的代码片段,以避免竞态条件的发生。

8.3 条件变量函数

8.3.1 初始化

int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr);
参数:
	cond:要初始化的条件变量
	attr:NULL

8.3.2 销毁

int pthread_cond_destroy(pthread_cond_t *cond)

8.3.3 等待

int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
参数:
	cond:要在这个条件变量上等待
	mutex:互斥量

8.3.4 唤醒等待

int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);

为什么 pthread_cond_wait 需要互斥量?

条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须 要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护。没有 互斥锁就无法安全的获取和修改共享数据。

由于解锁和等待不是原子操作。调用解锁之后, pthread_cond_wait之前,如果已经有其他线程获取到互斥量,摒弃条件满足,发送了信号,那么 pthread_cond_wait 将错过这个信号,可能会导致线程永远阻塞在这个pthread_cond_wait 。所以解锁和等待必须是一个原子操作。

int pthread_cond_wait(pthread_cond_ t *cond,pthread_mutex_ t * mutex); 进入该函数后,会去看条件量等于0不?等于,就把互斥量变成1,直到cond_ wait返回,把条件量改成1,把互斥量恢复成原样。

8.4 条件变量使用规范

条件变量典型一般模式用于线程等待某个条件的发生,然后在满足条件时通过条件变量的signal操作唤醒等待的线程。避免线程忙等待,提高资源利用率:

  1. 等待条件代码:

    • 首先,通过pthread_mutex_lock获取互斥锁。这是为了确保在等待条件的时候,其他线程不能干扰。
    • 接着,使用一个while循环来等待某个特定的条件变为真。while循环是必要的,因为在多线程环境下,线程可能因为条件不满足而被唤醒,但仍然需要检查一次条件是否满足。
    • while循环内部,调用pthread_cond_wait函数,它将释放互斥锁,并等待条件变量的信号。这时,线程会被阻塞,直到另一个线程通过pthread_cond_signalpthread_cond_broadcast发送信号。
  2. 修改条件:

    • 在等待条件的代码块之后,通过pthread_mutex_unlock释放互斥锁,允许其他线程再次进入临界区。
  3. 给条件发送信号代码:

    • 在某个线程满足条件时,它可以通过获取互斥锁、设置条件为真,然后调用pthread_cond_signal来通知等待的线程。这会唤醒一个等待的线程(如果有的话)。

这个模式确保了线程在等待条件的时候是被正确阻塞的,并且在条件满足时能够及时唤醒。需要注意的是,pthread_cond_wait内部会自动解锁互斥锁并进行等待,当线程被唤醒后会重新获取互斥锁,因此整个操作是线程安全的。

9、生产消费者模型

9.1 使用原因

生产者消费者模式是一种常见的并发编程模式,用于解决生产者和消费者之间的协作问题。下面我将进一步详细解释为什么要使用生产者消费者模型:

  1. 解耦生产者和消费者: 在许多情况下,生产者和消费者之间的处理速度不一致。如果生产者直接将数据交给消费者,可能会导致消费者需要等待,或者数据可能被消费者迅速处理完而丢失。生产者消费者模式通过引入一个缓冲区(阻塞队列)来解耦生产者和消费者,使得生产者可以快速生产数据,而消费者可以按照自己的速度进行消费。

  2. 平衡处理能力: 阻塞队列作为缓冲区,可以平衡生产者和消费者的处理能力。当生产者生产数据过快时,数据可以暂时存放在队列中,等待消费者处理。当消费者处理速度较快时,队列中的数据可以充当一个缓冲,避免消费者一直等待。

  3. 避免竞态条件: 在多线程环境中,生产者和消费者之间的共享资源可能会引发竞态条件,导致数据一致性问题。通过使用阻塞队列,可以避免直接访问共享资源,从而降低了出现竞态条件的可能性。

  4. 提高系统吞吐量: 生产者消费者模式能够充分利用系统资源,提高系统的吞吐量。生产者可以并行地生产数据,而消费者可以并行地从队列中获取数据进行处理,从而充分发挥多核处理器的性能。

  5. 简化编程: 使用生产者消费者模式,开发人员可以将生产者和消费者的逻辑分开处理,降低了代码的复杂性。每个线程只需关注自己的任务,而不需要考虑和其他线程的交互。

总之,生产者消费者模式通过引入缓冲区来解耦生产者和消费者,平衡处理能力,避免竞态条件,提高系统性能,以及简化编程,从而有效地优化多线程并发编程。

生产者消费者模型优点:

  • 解耦
  • 支持并发
  • 支持忙闲不均

9.2 阻塞队列(BlokingQueue)

阻塞队列的特点: 阻塞队列是一种具有特定行为的队列,它具备以下几个主要特点:

  1. 阻塞操作: 当尝试从空的队列中获取元素时,获取操作会被阻塞,直到队列中有元素为止。同样,当尝试往满的队列中放入元素时,插入操作会被阻塞,直到队列中有空位。
  2. 线程安全: 阻塞队列的实现通常是线程安全的,这意味着多个线程可以同时访问队列,而不需要额外的同步操作。

使用场景: 阻塞队列常用于生产者消费者模型,其中生产者线程往队列中放入数据,而消费者线程从队列中获取数据。这种模型适用于多个生产者和多个消费者的情况,阻塞队列可以有效协调各个线程的操作,避免数据竞争和死锁。

当使用单生产者单消费者模型时,只有一个生产者线程负责将数据放入队列,而只有一个消费者线程负责从队列中获取并处理数据。这种情况下,我们可以使用互斥锁(std::mutex)和条件变量(std::condition_variable)来实现线程之间的同步与协调。以下是一个示例:

#include 
#include 
#include 
#include 
#include 

std::queue<int> dataQueue; // 数据队列
std::mutex mtx; // 互斥锁,保证线程安全
std::condition_variable cv; // 条件变量,用于等待和通知

void producer() {
    for (int i = 0; i < 10; ++i) {
        std::this_thread::sleep_for(std::chrono::milliseconds(200)); // 模拟生产耗时
        std::lock_guard<std::mutex> lock(mtx); // 上锁,确保线程安全
        dataQueue.push(i); // 将数据放入队列
        std::cout << "Produced: " << i << std::endl;
        cv.notify_one(); // 通知消费者线程有数据可取
    }
}

void consumer() {
    while (true) {
        std::unique_lock<std::mutex> lock(mtx); // 上锁
        cv.wait(lock, []{ return !dataQueue.empty(); }); // 等待条件满足(队列不为空)
        int value = dataQueue.front(); // 获取队列中的数据
        dataQueue.pop(); // 移除队列头部的数据
        std::cout << "Consumed: " << value << std::endl;
    }
}

int main() {
    std::thread producerThread(producer); // 创建生产者线程
    std::thread consumerThread(consumer); // 创建消费者线程

    producerThread.join(); // 等待生产者线程结束
    consumerThread.join(); // 等待消费者线程结束

    return 0;
}

在这个示例中,生产者线程负责将数据放入队列,并通过条件变量通知消费者线程。消费者线程则在队列中有数据可消费时被唤醒,获取并处理数据。互斥锁用于保护数据队列的访问,确保线程安全。

请注意,消费者线程在等待时调用了cv.wait(),这会在等待过程中自动释放互斥锁,并在接收到通知后重新上锁,以避免竞态条件。

这个单生产者单消费者模型示例演示了如何使用互斥锁和条件变量来实现线程之间的同步和协调,确保生产者和消费者线程能够安全地协同工作。

10、POSIX信号量

POSIX信号量:

POSIX信号量(pthread信号量)主要用于线程间的同步。它是在POSIX标准中定义的,因此通常更容易在现代的多线程应用中使用。它提供了一种机制,允许线程在共享资源上进行同步,并且可以限制并发访问。POSIX信号量的API对线程进行了更好的支持,使得线程之间的同步更加方便。

接口函数

POSIX信号量提供了一组函数用于创建、操作和销毁信号量,实现多线程或多进程的同步和互斥。以下是一些常用的POSIX信号量接口函数以及它们的基本使用方法:

  1. 初始化信号量:

    int sem_init(sem_t *sem, int pshared, unsigned int value);
    
    • sem: 信号量指针
    • pshared: 如果为0,表示信号量在同一进程的不同线程间共享;如果为非0,表示信号量在进程间共享。
    • value: 信号量的初始值
  2. 销毁信号量:

    int sem_destroy(sem_t *sem);
    
    • sem: 要销毁的信号量指针
  3. 等待信号量(减少信号量值):

    int sem_wait(sem_t *sem);
    
    • sem: 信号量指针
  4. 尝试等待信号量:

    int sem_trywait(sem_t *sem);
    
    • sem: 信号量指针
  5. 增加信号量值:

    int sem_post(sem_t *sem);
    
    • sem: 信号量指针
  6. 获取信号量的值:

    int sem_getvalue(sem_t *sem, int *sval);
    
    • sem: 信号量指针
    • sval: 存放信号量的值

下面是一个简单的示例,演示了如何使用POSIX信号量来实现线程间的同步:

#include 
#include 
#include 

sem_t semaphore; // 信号量

void worker() {
    std::cout << "Worker is waiting..." << std::endl;
    sem_wait(&semaphore); // 等待信号量
    std::cout << "Worker is working!" << std::endl;
    sem_post(&semaphore); // 释放信号量
}

int main() {
    sem_init(&semaphore, 0, 1); // 初始化信号量,初始值为1

    std::thread t1(worker);
    std::thread t2(worker);

    t1.join();
    t2.join();

    sem_destroy(&semaphore); // 销毁信号量

    return 0;
}

在这个示例中,两个线程等待信号量,只有一个线程能够获取信号量并执行工作。通过使用sem_waitsem_post,我们实现了对共享资源的同步和互斥访问。

11、线程池

线程池是一种并发编程的设计模式,它可以有效地管理和复用线程,提高程序的性能和资源利用率。线程池维护一组预创建的线程,这些线程可以在需要时被重复使用来执行任务,而不必每次都创建和销毁线程。

线程池的主要优势包括:

  1. 减少线程创建和销毁的开销: 创建和销毁线程是比较昂贵的操作。使用线程池可以避免频繁地创建和销毁线程,从而降低开销。

  2. 提高任务执行效率: 线程池中的线程可以并发地执行任务,从而充分利用系统的多核处理能力,提高任务执行效率。

  3. 控制并发度: 线程池可以限制同时执行的线程数量,避免资源过度竞争,从而更好地控制并发度。

  4. 避免资源耗尽: 如果不使用线程池,大量的线程可能会占用系统的资源,导致资源耗尽和性能下降。线程池可以限制同时存在的线程数量,避免这种情况。

线程池通常由以下几个组成部分:

  1. 线程管理器(Thread Manager): 负责管理线程的创建、销毁和调度。它会维护一个线程池,管理线程的状态和数量。

  2. 任务队列(Task Queue): 存放待执行的任务。当有任务需要执行时,线程管理器会从任务队列中取出任务分配给空闲的线程。

  3. 工作线程(Worker Threads): 实际执行任务的线程。它们从任务队列中获取任务并执行,执行完成后可以继续等待下一个任务。

使用线程池的一般步骤包括:

  1. 初始化线程池,创建一组工作线程。
  2. 将需要执行的任务添加到任务队列中。
  3. 工作线程从任务队列中获取任务并执行。
  4. 执行完毕的工作线程可以继续等待新的任务,而不是销毁。

12、单例模式

12.1 设计模式

设计模式是一种在软件设计中广泛使用的经验总结,它是解决常见问题和设计挑战的一系列经过验证的解决方案。设计模式不是具体的代码片段,而是一种通用的设计思想和方法,可以在特定情况下指导软件设计者如何构建可维护、可扩展和可重用的软件系统。

设计模式的存在是为了解决软件开发中的一些常见问题,例如:

  1. 代码重用: 设计模式提供了经过验证的解决方案,可以帮助开发者在不同项目中重用代码,提高开发效率。

  2. 灵活性和可扩展性: 设计模式能够帮助开发者构建灵活的架构,使系统易于扩展和修改。

  3. 降低耦合度: 设计模式可以减少不同组件之间的耦合,使系统更加模块化,降低维护成本。

  4. 提高可读性: 使用设计模式可以使代码结构更加清晰,易于理解和维护。

设计模式通常由以下几个元素组成:

  1. 模式名称: 用来描述模式的名称,如“单例模式”、“工厂模式”等。

  2. 问题描述: 描述了在什么情况下需要使用该模式,遇到什么问题,以及问题的背景和条件。

  3. 解决方案: 提供了一个通用的解决方案,解释了如何设计类、对象和交互,以解决特定的问题。

  4. 效果: 描述了使用该模式的优点和缺点,以及如何在软件系统中应用它。

设计模式通常分为三种类型:

  1. 创建型模式(Creational Patterns): 用于处理对象的创建机制,包括类的实例化和对象的创建过程,如单例模式、工厂模式等。

  2. 结构型模式(Structural Patterns): 用于处理类和对象的组合,帮助构建更大的结构,如适配器模式、装饰者模式等。

  3. 行为型模式(Behavioral Patterns): 用于处理对象之间的交互和职责分配,如观察者模式、策略模式等。

设计模式是一种有助于软件开发的重要工具,它能够帮助开发者通过经验和实践来解决各种复杂的设计问题。然而,使用设计模式时应该根据具体情况谨慎考虑,避免过度使用,以免引入不必要的复杂性。

12.2 单例模式

单例模式是一种设计模式,用于确保一个类只有一个实例,并提供一个全局访问点来访问这个唯一实例。它确保在整个应用程序中只有一个实例存在,从而避免了重复创建相同对象的开销,同时也可以在需要时集中管理这个唯一实例的状态和行为。

单例模式通常适用于以下情况:

  1. 资源共享: 当多个部分需要共享同一个资源时,可以使用单例模式来确保只有一个实例,避免资源浪费和不一致的情况。

  2. 全局配置: 如果需要在整个应用程序中共享某些全局配置或设置,可以使用单例模式来管理这些设置的唯一实例。

  3. 数据库连接池: 在多线程环境下,使用单例模式可以确保只有一个数据库连接池实例,避免连接过多造成资源浪费。

  4. 日志记录器: 单例模式可以用于创建唯一的日志记录器,以确保日志信息被统一记录。

单例模式的一般实现方式是,将类的构造函数定义为私有的,从而阻止外部代码直接创建实例。然后,提供一个静态方法来获取类的唯一实例。如果实例不存在,则在该方法内部创建实例并返回,如果实例已存在,则直接返回已有实例。

12.3 线程安全性

单例模式与线程之间存在一些关系,特别是在多线程环境下,需要考虑单例模式的线程安全性。

在多线程环境下使用单例模式可能会引发以下问题:

  1. 并发创建多个实例: 如果多个线程同时尝试创建单例模式的实例,可能会导致创建多个实例,违反了单例模式的原则。

  2. 数据不一致: 如果多个线程同时访问单例模式的实例并修改其状态,可能会导致数据不一致的问题。

为了解决这些问题,需要确保单例模式在多线程环境下是线程安全的。有多种方法可以实现线程安全的单例模式:

  1. 饿汉方式: 在类加载时就创建实例,保证了实例的唯一性,但可能会造成资源浪费。

  2. 懒汉方式加锁: 在获取实例的方法中使用锁来保证只有一个线程能够创建实例。但是加锁会引入性能开销。

  3. 双重检查锁定: 在懒汉方式的基础上,使用双重检查来减少加锁的频率,提高性能。

  4. 静态内部类: 利用类加载机制和静态变量的特性,通过静态内部类来延迟加载实例。

  5. 枚举方式: 枚举是线程安全的单例模式,可以保证只有一个实例。

总之,在多线程环境下,确保单例模式的线程安全性非常重要,开发者需要根据具体情况选择合适的实现方式,以避免并发问题。

13、STL、智能指针与线程安全

13.1 STL

STL的设计注重了性能和灵活性,因此大多数STL容器并没有内建的线程安全机制。这意味着,在多线程环境下,如果多个线程同时访问和修改同一个STL容器,可能会导致竞态条件和数据不一致的问题。

为了在多线程环境中使用STL容器,开发者通常需要自己实施适当的线程安全机制,比如使用互斥锁来保护容器的访问。这可能会引入额外的开销,以确保数据的一致性和线程安全性。

然而,一些编译器和STL实现提供了一些线程安全的扩展,如C++11引入的一些线程安全的容器类。这些容器在一定程度上提供了线程安全性,但在实际使用中也需要根据具体情况进行评估和测试。

总之,当在多线程环境下使用STL容器时,开发者需要谨慎考虑并实施适当的线程安全机制,以保证数据的一致性和程序的正确性。

13.2 智能指针

  • unique_ptr: unique_ptr 在所有权范围内只允许一个指针访问资源,它的所有权在移动或者销毁时会自动释放资源。由于其独占性质,一般不涉及多线程的共享问题,因此在使用上是线程安全的。

  • shared_ptr: shared_ptr 允许多个智能指针共享同一个资源,内部有一个引用计数用来管理资源的生命周期。因为引用计数可能在多线程环境下被修改,所以在使用 shared_ptr 时涉及到线程安全问题。然而,C++标准库的实现通常会使用原子操作(如CAS,比较并交换)来确保引用计数的原子性,从而保证 shared_ptr 的线程安全性。

总结起来,unique_ptr 在本身的作用域内是线程安全的,而 shared_ptr 的线程安全性是由标准库的实现保证的,它能够在多线程环境下正常工作。不过,当使用 shared_ptr 时仍然需要遵循一些线程安全的编程实践,以防止潜在的问题。

你可能感兴趣的:(Linux,linux)