Linux-线程控制
Linux - 线程控制
一、线程概念
1)线程地址空间
线程与进程共享相同的虚拟地址空间,因此线程在访问内存时与进程没有本质的区别。但线程共享和独占的内存区域有不同的特点,理解这些特性对于正确使用线程至关重要。
1. 线程地址空间的组成
线程的地址空间是进程地址空间的一部分。所有线程共享进程的全局地址空间,同时也有自己的独立部分。
共享部分
代码段:
包含程序的可执行指令,所有线程共享这段内存。通常是只读的。
数据段:
包含初始化的全局变量和静态变量,所有线程共享。
BSS 段:
包含未初始化的全局变量和静态变量,所有线程共享。
堆:
用于动态分配内存(如
malloc
或new
),所有线程共享。内存映射区域:
通过
mmap
等函数分配的内存,所有线程共享。
独立部分
栈:
每个线程有自己的独立栈,存储局部变量、函数调用帧、返回地址等。
线程局部存储(TLS):
每个线程都有独立的线程局部存储空间,用于存储线程独占的变量。
寄存器上下文:
每个线程有独立的寄存器,包括程序计数器(PC)、栈指针(SP)等,用于管理线程的执行状态。
2. 地址空间布局
以下是多线程程序典型的虚拟地址空间布局示意图:
+--------------------------+ High Memory Address
| 内核空间 | <- 仅内核访问
+--------------------------+
| 栈 (线程 N) | <- 每个线程独立栈空间
| 栈 (线程 2) |
| 栈 (线程 1) |
+--------------------------+
| 堆 | <- 动态分配的内存 (共享)
+--------------------------+
| 已初始化数据段 (.data) | <- 全局和静态变量 (共享)
| 未初始化数据段 (.bss) |
+--------------------------+
| 可执行代码段 (.text) | <- 程序代码 (共享)
+--------------------------+
| 动态链接库 (共享库) |
+--------------------------+
| 内存映射区域 | <- mmap 区域 (共享)
+--------------------------+ Low Memory Address
3. 共享与独立的作用与意义
共享部分
- 全局变量和静态变量(.data/.bss 段)
- 所有线程共享,因此修改一个线程中的全局变量会影响其他线程。
- 易引发数据竞争,需要使用同步机制(如互斥锁或信号量)。
- 堆
- 堆是动态分配内存的区域,所有线程共享。
- 动态内存分配(如
malloc
或new
)需要线程安全的实现,如线程安全的分配器。
独立部分
- 栈
- 每个线程有自己的栈空间,用于存储局部变量、函数调用帧等。
- 栈空间独立,线程间的局部变量不会互相干扰。
- 寄存器上下文
- 每个线程有自己的程序计数器、栈指针等,线程的执行状态独立。
- 线程局部存储(TLS)
- 提供每个线程独占的变量存储区,适合线程私有数据。
4. 栈的独立性
栈的独立性是线程的重要特点。每个线程的栈:
- 在创建线程时由内核分配。
- 栈的大小可以通过
pthread_attr_setstacksize
或 C++11 的线程库配置。 - 从高地址向低地址扩展。
示例代码展示线程栈的独立性:
cpp复制编辑#include <iostream>
#include <thread>
void thread_function(int id) {
int local_var = id; // 栈上的局部变量
std::cout << "Thread " << id << " | Address of local_var: " << &local_var << std::endl;
}
int main() {
std::thread t1(thread_function, 1);
std::thread t2(thread_function, 2);
t1.join();
t2.join();
return 0;
}
输出示例:
less复制编辑Thread 1 | Address of local_var: 0x7fff5f3f7000
Thread 2 | Address of local_var: 0x7fff5f2f7000
观察:
- 两个线程的
local_var
地址不同,说明各线程有独立的栈空间。
2)同步与互斥
在多线程或多进程环境中,同时操作共享资源或需要协作完成任务是常见需求。而 同步 和 互斥 是解决并发问题的两种关键技术,它们的目标是确保系统的正确性、稳定性和高效性。
1. 同步
同步 (Synchronization)是指为了保证多个线程或进程按照正确的逻辑顺序执行,协调它们的操作,使得某些事件或条件满足后,其他线程才能继续执行。
强调协作 :线程之间需要等待其他线程完成某些操作,常用于线程之间的依赖关系场景。
可能会阻塞 :线程在等待条件满足时,可能会进入等待状态,直到相关条件被触发。
常见机制 :
- 条件变量 :线程通过条件变量进行等待或通知操作。
- 信号量 :控制线程的运行顺序或资源计数。
- 屏障(Barrier) :确保多个线程在某个阶段完成后再进入下一个阶段。
常见应用场景 :
- 生产者-消费者模型。
- 多线程任务的阶段性同步。
2. 互斥
互斥 (Mutual Exclusion)是指为了防止多个线程同时访问共享资源而引发的竞争条件,确保同一时间内只有一个线程能够访问资源。
- 强调独占访问 :保证资源一次只被一个线程访问,防止并发修改带来的不一致性。
- 避免数据竞争 :通过互斥保护临界区,确保共享数据的完整性和一致性。
- 可能会阻塞 :线程在尝试获取锁失败时,会被阻塞,直到其他线程释放锁。
- 常见机制
:
- 互斥锁(Mutex) :实现线程对资源的独占访问。
- 读写锁(Read-Write Lock) :区分读操作和写操作,提高读操作的并发性。
- 自旋锁(Spinlock) :线程忙等待获取锁,适用于短时间锁定场景。
- 常见应用场景
:
- 临界区保护。
- 文件、内存或数据库的共享资源访问。
- 寄存器是 CPU 的硬件资源,线程在执行时加载共享数据到寄存器,将数据内容拷贝到自身的上下文中独立操作。这种方式提升了访问速度,但也引入了数据一致性问题,寄存器只是数据的载体,而不是数据本身。即使两个线程可以访问相同的寄存器,其内容可能因上下文切换或不同线程的操作而不同。
- 锁操作本身就是被设计成原子的
3. 饥饿
饥饿问题 (Starvation)是并发程序设计中的一个常见问题,指的是在多线程或多进程环境中,由于调度策略或资源竞争,某些线程或进程长时间无法获得所需的资源,导致无法执行或完成任务。这种情况通常发生在某些线程或进程频繁被其他线程或进程抢占或延迟,造成它们无法得到足够的执行时间。
引起原因:
- 不公平的资源分配 :如果资源分配策略不公平,一些线程可能会总是被其他线程阻塞,无法获得资源。例如,某些线程总是被调度到而其他线程始终得不到执行机会。
- 调度策略问题 :在某些调度算法中(如优先级调度),低优先级的线程可能永远无法获得 CPU 时间片,因为高优先级的线程总是优先执行。
- 死锁相关问题 :虽然死锁通常意味着所有线程都被阻塞,但某些情况下,部分线程可能会处于一种长期等待状态,无法获得资源,导致它们“饥饿”。
- 不当的锁或信号量使用 :如果一个线程持有锁时间过长,而其他线程需要这些资源,它们可能长时间无法执行。
解决方法
- 公平调度算法
:
- 轮转调度(Round-robin scheduling) :这种算法确保每个线程都能轮流获得 CPU 时间片,从而避免某些线程长时间得不到执行。
- 公平锁(Fair Mutex)
:保证多个线程公平地访问共享资源,比如使用“公平互斥锁”(
pthread_mutex_t
的某些变种可以提供公平性)。 - 优先级反转避免饥饿 :一些实时操作系统(RTOS)采用优先级继承机制,即当高优先级线程等待低优先级线程释放锁时,低优先级线程的优先级会被临时提升,从而避免低优先级线程一直无法获得锁。
- 优先级调整
:
- 动态调整优先级 :某些系统采用动态优先级调整策略,即低优先级的线程经过一段时间后,优先级会逐步提高,以确保它们最终能获得执行机会。
- 老化机制(Aging Mechanism) :这种机制通过逐步增加线程的优先级来避免其长时间得不到执行,从而避免饥饿。
- 资源限制与控制
:
- 对资源进行限制和分配,可以使用信号量、互斥锁等同步原语来控制资源的访问顺序,避免个别线程在资源竞争中始终无法获得资源。
- 增加等待时间上限 :为了防止某些线程在等待资源时被无限期阻塞,可以设置等待时间的上限(如最大等待次数或最大超时),一旦超过上限,线程就会被重新调度或执行其他任务。
- 优先级公平性
:
- 使用优先级公平调度算法,确保所有线程都有机会执行,避免低优先级线程始终得不到执行机会。
- 公平调度算法
:
3)可重入与线程安全
1. 可重入
可重入 指的是一种程序或函数的特性,即在被中断后,如果中断处理程序再次调用该函数,原先的调用不会受到影响,能够正确地执行并返回预期结果。换句话说,可重入函数是线程安全的,但不仅限于多线程环境。
特性
- 不依赖于全局或静态变量:函数内部使用的所有变量都必须是局部变量,或者通过参数传递。
- 不修改共享资源:函数中不能操作或修改共享的全局资源。
- 不调用不可重入的函数:如果一个函数调用了非可重入的函数,则它本身也不可重入。
- 无状态依赖:函数的行为不依赖于之前的调用状态。
- 无阻塞操作:函数中不能使用可能引起阻塞的系统调用,例如动态内存分配(
malloc
)或文件I/O操作。
意义:
- 线程安全 :在多线程环境中,多个线程可以并发地调用该函数,而不会发生数据竞争或资源冲突。
- 中断安全 :在中断处理程序中调用不会破坏原先的执行状态。
2. 线程安全
线程安全 是指多个线程可以并发执行某个函数或操作时,不会由于竞争条件而导致不一致的结果。具体来说,线程安全的函数在多个线程同时调用时,不会因为资源竞争、状态冲突等问题而导致程序出现错误。通常,线程安全涉及对共享资源(如全局变量、静态变量)的保护。
特性
- 锁机制 :线程安全的函数通常会使用锁(如互斥锁、读写锁)来保护共享资源,确保在某一时刻只有一个线程能够访问资源。
- 无数据竞争 :所有线程对共享资源的访问都会被正确同步,从而避免数据竞争(race condition)。
- 一致性 :线程安全的函数保证了多线程操作时数据的一致性,避免不同线程的操作交错导致结果不可预知。
线程安全的实现方式 :
- 互斥锁(mutex) :使用互斥锁可以保护对共享资源的访问,确保每次只有一个线程能够访问该资源。
- 原子操作
:一些操作(如
atomic
操作)可以保证在多线程环境下的操作是不可中断的,从而避免了竞争条件。 - 线程局部存储(TLS) :在某些场景中,使用线程局部存储可以避免线程间的资源竞争,因为每个线程都持有自己的数据副本。
3. 可重入与线程安全
关系 :
可重入是线程安全的一种形式 :
如果一个函数是 可重入的 ,那么它必定是线程安全的,因为可重入函数的特性使得它能够在多个线程中独立运行,不会发生资源冲突和数据竞争。
然而, 线程安全的函数不一定是可重入的 。线程安全的函数可能依赖于全局状态或者使用锁来同步资源,这会导致它在多个执行流(如多线程和中断)中不再保持可重入性。
可重入函数一定是线程安全的 :
- 由于可重入函数在并发执行时能够保持数据一致性,因此它必然能够在多线程环境中保证线程安全。
线程安全函数不一定是可重入的 :
- 如果一个线程安全的函数使用了全局资源或锁来确保线程同步,那么它就不是可重入的,因为中断或另一个线程的调用可能会打乱其内部状态,导致死锁或其他问题。
区别:
特性 可重入 线程安全 定义 函数在被中断后,仍能被其他执行流(如线程)调用,而不会影响执行结果。 多线程环境下,多个线程同时调用函数时,不会发生数据竞争或不一致的结果。 依赖的资源 不依赖全局或静态变量,所有数据由函数调用者提供。 可能依赖全局或静态变量,但通过锁机制或其他方式保证线程间访问时的一致性。 多线程环境 可重入的函数在多线程中也一定是线程安全的。 线程安全的函数不一定是可重入的。 常见的线程安全措施 使用局部变量、无共享资源、无外部状态依赖。 使用锁、原子操作、线程局部存储(TLS)等方式保证线程间的同步。 执行流 可被多个执行流(包括中断)多次调用,不受其他执行流影响。 通过同步机制确保多个线程对资源的访问不会发生冲突。
4) 死锁
死锁 是多线程或多进程程序中的一个常见问题,指的是两个或多个线程或进程在执行过程中,由于争夺资源而导致相互等待,最终无法继续执行的状态。
1. 死锁的四个必要条件
根据 Carson & Hoare 的死锁四条件 ,死锁的发生需要满足以下四个必要条件:
- 互斥条件(前提) :至少有一个资源是不能共享的,即在同一时刻只能由一个线程或进程占用。如果其他线程或进程请求该资源,则必须等待。
- 占有并等待条件(原则) :一个线程或进程已经占有了至少一个资源,并且在等待其他线程或进程持有的资源。
- 非抢占条件(原则) :资源不能被强行抢占。即线程持有资源时,其他线程不能强制夺回该资源,只能等待该线程释放资源。
- 循环等待条件(重要条件) :存在一个线程(或进程)等待链,链中的每个线程都在等待下一个线程持有的资源,形成一个环状的等待链。
2. 死锁的避免
死锁避免是通过动态地分析系统的资源分配情况,并采取适当的策略,确保死锁不会发生。最常见的策略有:
资源请求的顺序一致 :
为每个资源分配一个编号,线程在请求资源时,按照从小到大的顺序请求资源。这样避免了形成循环等待的条件。
银行家算法(Banker’s Algorithm):
银行家算法是一种避免死锁的资源分配算法。在分配资源之前,先判断分配后是否会进入一个安全状态。如果分配后会导致死锁,就不分配资源,直到系统进入安全状态。
避免占有并等待:
线程在请求资源时,要求一次性申请所有需要的资源,而不是逐个请求资源。这样可以避免一个线程持有部分资源并等待其他资源的情况。
避免循环等待:
通过对资源进行排序,确保系统中不会形成循环等待。例如,线程在请求资源时,必须按照资源的顺序来请求资源,避免形成循环等待。
5)信号量
信号量是多线程和多进程同步中的一种机制,用于控制多个线程或进程对共享资源的访问。信号量是一种 计数器 ,它用来控制对共享资源的访问数量。
1. 信号量的基本概念
信号量可以视为一个维护共享资源数量的整数,本质就是一个计数器,表示当前系统中可以访问该资源的线程数量。信号量的常见类型有两种:
- 计数信号量(Counting Semaphore) :允许任意数量的线程访问共享资源。它的值可以是任意非负整数,表示当前可用的资源数量。常用于控制多个资源的并发访问。
- 二进制信号量(Binary Semaphore)
:是一个特殊的计数信号量,只允许值为
0
或1
,相当于一个互斥锁。常用于二进制状态的同步,如资源的占用与释放。
2. 信号量的基本操作
信号量通常有两种基本操作:
- P操作(或称等待操作、减操作):
P
操作会使信号量的值减一。当信号量的值大于0时,线程会继续执行;如果信号量的值为0,线程将被阻塞,直到其他线程执行 V 操作增加信号量的值。
- V操作(或称释放操作、加操作):
V
操作会使信号量的值加一。当信号量的值增加时,如果有线程被阻塞在P
操作中,系统会唤醒一个线程继续执行。
3. 信号量的应用场景
信号量主要用于以下几种场景:
- 资源访问控制 :当有多个线程或进程需要共享有限的资源时,可以使用信号量来限制并发访问的数量。例如,数据库连接池、线程池等都可以使用信号量来限制并发连接数。
- 同步互斥 :信号量可以用于线程之间的同步,确保不同线程按照一定顺序执行。例如,通过信号量保证线程按顺序执行或等待某些条件发生。
- 生产者-消费者问题 :信号量通常用于解决生产者-消费者模型中的同步问题。生产者线程和消费者线程之间通过信号量协调共享缓冲区的使用。
- 任务调度 :当多个线程需要处理多个任务并且每个任务可能需要不同的资源时,信号量可以协调任务调度,保证有限资源不会被多个任务同时占用。
4. 信号量与同步操作
进程同步接口 (如互斥锁和条件变量)更适合于 保护资源的独占访问 和 线程间同步 ,而 信号量 则用于控制对资源的并发访问,适合于需要 控制多个资源的并发访问 的场景。
特性 | 进程同步接口 | 信号量 |
---|---|---|
用途 | 主要用于资源的互斥访问和线程同步。 | 主要用于控制并发访问资源的数量。 |
操作原语 | pthread_mutex_lock() , pthread_cond_wait() , pthread_cond_signal() 等 | sem_wait() , sem_post() 等 |
适用场景 | 保护共享资源、线程间的协调和通信、条件等待等。 | 控制多个线程对共享资源的访问,避免过多线程并发操作资源。 |
灵活性 | 适用于访问单一共享资源的同步控制。 | 适用于多个资源的访问控制,支持并发控制。 |
简单性 | 操作简单,适用于资源保护。 | 提供更多的控制选项,适合多个资源的并发访问。 |
二、线程库
1)线程控制
1. 进程创建
pthread_creat()
:用于创建新线程的函数。它允许程序在同一个进程中并发执行多个任务。int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg)
#include <stdio.h> #include <pthread.h> #include <stdlib.h> #include <unistd.h> // 线程函数 void *thread_function(void *arg) { int *num = (int *)arg; // 将参数转换为适当类型 printf("Thread %d is running\n", *num); sleep(1); // 模拟任务 printf("Thread %d has finished\n", *num); return NULL; } int main() { pthread_t threads[5]; int thread_args[5]; for (int i = 0; i < 5; i++) { thread_args[i] = i + 1; // 参数传递 if (pthread_create(&threads[i], NULL, thread_function, &thread_args[i]) != 0) { perror("Failed to create thread"); return 1; } } // 等待线程结束 for (int i = 0; i < 5; i++) { pthread_join(threads[i], NULL); } printf("All threads have completed\n"); return 0; }
thread
:用于存储创建线程的线程 ID,系统自动分配。(输出型参数)attr
:用于设置线程的属性(例如是否为分离线程、栈大小等)。NULL
:使用默认属性。(一般都用该设置)
start_routine
:线程执行的函数。arg
:传递给start_routine
函数的参数。- 返回值:
0
:成功创建线程。- 非
0
:返回错误码,表示线程创建失败。
pthread_create
本身 不是直接的系统调用 ,编译时需要自己链接该库:-lpthread
。- 返回的线程与系统中的
LWP
并不是同一个东西。、
2. 线程等待
pthread_join()
:用于阻塞调用线程,直到指定的目标线程终止为止。通常用于等待线程的完成,并获取线程的返回值。int pthread_join(pthread_t thread, void **retval)
#include <pthread.h> #include <stdio.h> void *thread_function(void *arg) { printf("Thread is running with arg: %d\n", *(int *)arg); pthread_exit((void *)42); // 返回值 } int main() { pthread_t thread; int arg = 10; void *retval; // 创建线程 if (pthread_create(&thread, NULL, thread_function, &arg) != 0) { perror("Failed to create thread"); return 1; } // 等待线程完成 if (pthread_join(thread, &retval) != 0) { perror("Failed to join thread"); return 1; } printf("Thread completed with return value: %ld\n", (long)retval); return 0; }
thread
:要等待的线程ID(pthread_create
的第一个参数返回的值)。retval
:用于存储目标线程的退出返回值。- 如果目标线程使用
pthread_exit
返回了某个值,该值会通过此参数返回给调用线程。 NULL
:若不需要返回值。
- 如果目标线程使用
- 返回值:成功返回
0
,失败返回错误码。
3. 线程退出
pthread_exit()
:用于让当前线程安全地退出,同时向其他线程传递返回值。void pthread_exit(void *retval)
pthread_exit((void *)42);
retval
:调用线程的退出状态,通常用来传递返回值给其他线程。如果该线程被其他线程pthread_join
,则retval
会通过pthread_join
的第二个参数返回。
4. 线程取消
pthread_cancel()
:用于向目标线程发送取消请求,要求其终止。目标线程是否响应取决于其取消状态和取消类型。int pthread_cancel(pthread_t thread)
pthread_create(&thread, NULL, thread_function, NULL) pthread_cancel(thread)
thread
:要发送取消请求的目标线程ID。- 返回值:成功返回
0
,失败返回错误码。
2)互斥锁
1. 初始化互斥锁
pthread_mutex_init()
: 用于初始化互斥锁(pthread_mutex_t
类型)的函数。它允许开发者创建一个互斥锁,并为锁设置指定的属性。int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr)
mutex
:指向要初始化的互斥锁对象的指针(pthread_mutex_t
类型)。(通常是一个未初始化的全局或静态变量)attr
:指向互斥锁属性对象的指针(pthread_mutexattr_t
类型)。(如果不需要特定的属性,可以传入NULL
,此时互斥锁将使用默认属性。)- 返回值:成功返回
0
,失败返回错误码。
静态初始化
如果互斥锁在程序中是全局变量,可以通过静态初始化代替动态初始化:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
静态初始化 确实不需要显式的初始化(
init
)和销毁(destroy
)操作。
2. 加锁
pthread_mutex_lock()
:是用于加锁互斥锁的函数。当线程需要进入一个临界区时,可以通过调用这个函数加锁,从而防止其他线程同时访问共享资源。int pthread_mutex_lock(pthread_mutex_t *mutex)
mutex
:指向需要加锁的互斥锁对象(pthread_mutex_t
类型)的指针。- 返回值:成功返回
0
,失败返回错误码。
- 加锁的本质是用时间换安全。因此需要保证临界区代码越少越好。
pthread_mutex_trylock()
:与pthread_mutex_lock
不同,pthread_mutex_trylock
不会阻塞当前线程。如果互斥锁已经被其他线程持有,pthread_mutex_trylock
会立即返回,而不是让当前线程等待。int pthread_mutex_trylock(pthread_mutex_t *mutex)
mutex
:指向互斥锁对象(pthread_mutex_t
类型)的指针,表示要尝试获取的锁。- 返回值:成功返回
0
,失败返回错误码。
3. 解锁
pthread_mutex_unlock()
:用于解锁互斥锁的函数。当线程完成对共享资源的操作后,调用该函数释放互斥锁,以便其他线程可以获取锁并访问临界区。int pthread_mutex_unlock(pthread_mutex_t *mutex)
mutex
:指向需要解锁的互斥锁对象(pthread_mutex_t
类型)的指针。- 返回值:成功返回
0
,失败返回错误码。
4. 销毁锁
pthread_mutex_destroy()
:用于销毁一个已经初始化的互斥锁(pthread_mutex_t
类型)的函数。在不再需要使用互斥锁时,应调用该函数释放分配的资源。int pthread_mutex_destroy(pthread_mutex_t *mutex)
mutex
:指向需要销毁的互斥锁对象的指针(pthread_mutex_t
类型)。- 返回值:成功返回
0
,失败返回错误码。
#include <pthread.h>
#include <stdio.h>
pthread_mutex_t mutex;
void *thread_function(void *arg) {
pthread_mutex_lock(&mutex); // 加锁
printf("Thread %ld: Entered critical section\n", (long)arg);
// 模拟临界区操作
sleep(1);
pthread_mutex_unlock(&mutex); // 解锁
printf("Thread %ld: Exited critical section\n", (long)arg);
return NULL;
}
int main() {
pthread_t t1, t2;
pthread_mutex_init(&mutex, NULL);
pthread_create(&t1, NULL, thread_function, (void *)1);
pthread_create(&t2, NULL, thread_function, (void *)2);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_mutex_destroy(&mutex);
return 0;
}
3)同步
1. 条件变量( pthread_mutex_t
)
- 条件变量用于线程之间的通信和协调,它通常与互斥锁(
pthread_mutex_t
)一起使用。条件变量允许线程在某些条件满足时进行同步,解决了线程之间的等待和通知问题。 - 条件变量的主要作用是使得线程可以在某个条件不满足时 等待 ,直到条件满足时再被唤醒执行。它允许线程在等待某个特定条件时不占用 CPU 资源(即避免忙等待),直到收到其他线程的通知。
2. 等待条件变量
pthread_cond_wait()
:用于使当前线程在条件变量上等待,并释放互斥锁,直到其他线程通过pthread_cond_signal()
或pthread_cond_broadcast()
唤醒它。int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex)
cond
:指向条件变量对象(pthread_cond_t
类型)的指针,表示要等待的条件变量。mutex
:指向互斥锁对象(pthread_mutex_t
类型)的指针,表示在等待期间保护的资源。- 返回值
:成功返回
0
,失败返回错误码。
3. 唤醒条件变量
pthread_cond_signal()
:用于唤醒一个等待在指定条件变量上的线程。只有一个等待线程会被唤醒。如果没有线程在等待,调用该函数不会有任何效果。int pthread_cond_signal(pthread_cond_t *cond)
cond
:指向条件变量对象(pthread_cond_t
类型)的指针,表示要发送信号的条件变量。- 返回值
:成功返回
0
,失败返回错误码。
pthread_cond_broadcast()
:用于唤醒所有等待在指定条件变量上的线程。int pthread_cond_broadcast(pthread_cond_t *cond)
cond
:指向条件变量对象(pthread_cond_t
类型)的指针,表示要发送信号的条件变量。- 返回值
:成功返回
0
,失败返回错误码。
pthread_cond_signal()
调用时会释放锁,返回时会持有锁。
4)信号量
1. 初始化信号量
sem_init()
:用于初始化一个信号量。信号量可以用于多线程或多进程间的同步,sem_init
设置了信号量的初始值。int sem_init(sem_t *sem, int pshared, unsigned int value)
sem_t sem; sem_init(&sem, 0, 1); // 初始化一个值为 1 的信号量
sem
:指向信号量的指针。pshared
:指定信号量的共享范围。0
表示信号量在线程间共享,1
表示信号量在进程间共享。value
:初始化信号量的值,通常表示可用资源的数量。- 返回值:成功返回
0
,失败返回错误码。
2. 等待信号量
sem_wait()
:信号量的等待操作(P操作)。如果信号量的值大于 0,线程将继续执行并将信号量减 1;如果信号量的值为 0,线程将被阻塞,直到信号量的值大于 0。int sem_wait(sem_t *sem)
sem_t sem; sem_wait(&sem); // 等待空槽位
sem
:指向信号量的指针。- 返回值:成功返回
0
,失败返回错误码。
3. 释放信号量
sem_post()
:信号量的释放操作(V操作)。将信号量的值增加 1,如果有线程因sem_wait()
被阻塞,则唤醒一个线程。int sem_post(sem_t *sem);
sem_t sem; sem_post(&sem); // 通知消费者缓冲区有数据
sem
:指向信号量的指针。- 返回值:成功返回
0
,失败返回错误码。
4. 销毁信号量
sem_destroy()
:销毁一个已初始化的信号量,释放资源。销毁前信号量应不再被任何线程使用。int sem_destroy(sem_t *sem)
sem_destroy(&sem); // 销毁信号量
sem
:指向信号量的指针。- 返回值:成功返回
0
,失败返回错误码。
三、多线程问题
1)生产者-消费者模型
生产者-消费者模型 (Producer-Consumer Model)是一种经典的多线程问题,主要用于描述多个线程如何共享资源并进行数据交换。在这个模型中,有两个主要的角色:生产者和消费者,它们通过某种形式的缓冲区(通常是队列)交换数据。
1. 模型背景与概述
生产者-消费者模型是多线程编程中的经典同步问题,它的目标是协调多个线程的操作,确保生产者和消费者之间的交互不会发生冲突。具体来说:
- 生产者 :生产者线程负责产生数据,并将数据放入共享缓冲区或队列中。
- 消费者 :消费者线程负责从共享缓冲区或队列中取出数据并消费。
缓冲区的大小是有限的,因此生产者和消费者之间需要协调:如果缓冲区已满,生产者需要等待;如果缓冲区为空,消费者需要等待。
2. 问题的关键点
一种交易场所:
- 特定结构的内存空间
两种角色:
- 生产者
- 消费者
三种关系:
- 生产者-生产者:互斥
- 消费者-消费者:互斥
- 生产者-消费者:互斥,同步
3. 优点
缓解不平衡问题
在许多应用中,生产者和消费者的工作速度往往是不一样的。生产者可能产生数据的速度比消费者消费数据的速度要快,或者反过来。通过生产者-消费者模型,能够有效地解决这种 忙闲不均 的问题。
生产者忙,消费者闲 :当生产者的速度远大于消费者时,生产者会将生成的数据放入缓冲区,消费者可以在其有空时从缓冲区中取出数据进行消费。
例如,在视频流处理中,生产者(视频流产生器)可能生成数据的速度远高于消费者(播放器),缓冲区可以容纳大量数据,确保播放过程不中断。
消费者忙,生产者闲 :如果消费者处理数据的速度超过生产者,则消费者可能会等待生产者生产更多的数据。在这种情况下,生产者在有能力时可以开始生产数据,而消费者则能够快速地消费这些数据。
缓冲区的作用 :缓冲区作为生产者和消费者之间的中介,平衡了生产者和消费者之间的不平衡问题。当生产者忙时,数据被放入缓冲区;当消费者忙时,数据等待在缓冲区中,直到消费者有空。
解耦生产与消费
生产者-消费者模型通过解耦生产和消费,使得两者的工作可以独立进行,这带来了更高的灵活性和扩展性:
- 生产与消费的独立性 :生产者和消费者在同一个系统中各自独立地运行。生产者只关心如何生产数据,消费者只关心如何消费数据,二者之间没有直接的依赖。它们通过共享的缓冲区进行交互,缓解了它们之间的紧密耦合。
- 灵活的负载调度 :由于生产者和消费者是解耦的,当生产者和消费者的工作负载发生变化时,系统能够根据需要进行动态调整。例如,可以增加更多的消费者线程以提高消费速率,或者增加生产者线程以提高生产速度,而不会影响另一方的操作。
- 扩展性 :在需要提升系统的吞吐量时,可以独立地增加生产者或消费者的数量,而无需改变系统的结构或流程。通过增加生产者,可以提升数据生成的速率;通过增加消费者,可以提升数据消费的速率。这样系统的扩展变得更加灵活和高效。
- 异步处理 :生产者和消费者不需要等待对方的操作完成,允许它们在不同的时间点进行工作。这种异步的处理方式使得系统能够高效地利用资源,减少等待时间,提高整体性能。
生产者与消费者的协调
虽然生产者和消费者解耦,互不直接依赖,但它们之间仍然需要协调和同步,以确保数据的正确处理。使用 条件变量 和 互斥锁 等同步机制能够协调生产者与消费者的工作:
- 生产者等待条件
:生产者在缓冲区已满时会等待,直到消费者消费了数据腾出空间。生产者通过条件变量(
pthread_cond_t
)来等待并被唤醒。 - 消费者等待条件 :消费者在缓冲区为空时会等待,直到生产者生产了数据填充缓冲区。消费者也通过条件变量来等待并被唤醒。
- 生产者等待条件
:生产者在缓冲区已满时会等待,直到消费者消费了数据腾出空间。生产者通过条件变量(
生产者-消费者模型(互斥锁与条件变量):
#include <pthread.h> #include <stdio.h> #include <unistd.h> #define MAX_ITEMS 10 // 缓冲区的最大容量 int buffer[MAX_ITEMS]; // 缓冲区 int count = 0; // 当前缓冲区中的项目数 pthread_mutex_t mutex; // 互斥锁,保护对缓冲区的访问 pthread_cond_t cond_full; // 条件变量,表示缓冲区已满 pthread_cond_t cond_empty; // 条件变量,表示缓冲区为空 // 生产者线程 void* producer(void* arg) { for (int i = 0; i < 20; i++) { pthread_mutex_lock(&mutex); // 如果缓冲区已满,等待 while (count == MAX_ITEMS) { pthread_cond_wait(&cond_full, &mutex); } // 生产一个新项目 buffer[count] = i; count++; printf("Produced: %d\n", i); // 通知消费者缓冲区有数据可以消费 pthread_cond_signal(&cond_empty); pthread_mutex_unlock(&mutex); sleep(1); // 模拟生产过程 } return NULL; } // 消费者线程 void* consumer(void* arg) { for (int i = 0; i < 20; i++) { pthread_mutex_lock(&mutex); // 如果缓冲区为空,等待 while (count == 0) { pthread_cond_wait(&cond_empty, &mutex); } // 消费一个项目 int item = buffer[count - 1]; count--; printf("Consumed: %d\n", item); // 通知生产者缓冲区有空间可以生产 pthread_cond_signal(&cond_full); pthread_mutex_unlock(&mutex); sleep(2); // 模拟消费过程 } return NULL; } int main() { pthread_t prod, cons; pthread_mutex_init(&mutex, NULL); pthread_cond_init(&cond_full, NULL); pthread_cond_init(&cond_empty, NULL); // 创建生产者和消费者线程 pthread_create(&prod, NULL, producer, NULL); pthread_create(&cons, NULL, consumer, NULL); // 等待线程结束 pthread_join(prod, NULL); pthread_join(cons, NULL); // 销毁互斥锁和条件变量 pthread_mutex_destroy(&mutex); pthread_cond_destroy(&cond_full); pthread_cond_destroy(&cond_empty); return 0; }
生产者线程 :
- 生产者线程通过
pthread_mutex_lock
获取互斥锁来操作共享缓冲区,防止其他线程同时访问缓冲区。- 如果缓冲区已满,生产者通过
pthread_cond_wait
等待消费者线程消费数据,直到缓冲区有空间。- 每次生产完数据后,生产者调用
pthread_cond_signal
唤醒消费者线程,通知其可以消费数据。消费者线程 :
- 消费者线程通过
pthread_mutex_lock
获取互斥锁来操作共享缓冲区。- 如果缓冲区为空,消费者通过
pthread_cond_wait
等待生产者生产数据,直到缓冲区有数据。- 每次消费完数据后,消费者调用
pthread_cond_signal
唤醒生产者线程,通知其可以继续生产。条件变量 :
pthread_cond_wait
:当条件不满足时(如缓冲区已满或为空),线程将阻塞,释放互斥锁并进入等待状态。pthread_cond_signal
:通知一个等待的线程,唤醒它继续执行。- 判断必须放在锁里
生产者-消费者模型(信号量):
#include <pthread.h> #include <stdio.h> #include <unistd.h> #include <semaphore.h> // 包含信号量头文件 #define MAX_ITEMS 10 // 缓冲区的最大容量 int buffer[MAX_ITEMS]; // 缓冲区 int count = 0; // 当前缓冲区中的项目数 sem_t empty_slots; // 信号量,表示空槽位 sem_t full_slots; // 信号量,表示已占用的槽位 pthread_mutex_t mutex; // 互斥锁,保护对缓冲区的访问 // 生产者线程 void* producer(void* arg) { for (int i = 0; i < 20; i++) { sem_wait(&empty_slots); // 等待空槽位 pthread_mutex_lock(&mutex); // 访问缓冲区前加锁 // 生产一个新项目 buffer[count] = i; count++; printf("Produced: %d\n", i); pthread_mutex_unlock(&mutex); // 解锁 sem_post(&full_slots); // 通知消费者缓冲区有数据 sleep(1); // 模拟生产过程 } return NULL; } // 消费者线程 void* consumer(void* arg) { for (int i = 0; i < 20; i++) { sem_wait(&full_slots); // 等待已占用槽位 pthread_mutex_lock(&mutex); // 访问缓冲区前加锁 // 消费一个项目 int item = buffer[count - 1]; count--; printf("Consumed: %d\n", item); pthread_mutex_unlock(&mutex); // 解锁 sem_post(&empty_slots); // 通知生产者缓冲区有空槽位 sleep(2); // 模拟消费过程 } return NULL; } int main() { pthread_t prod, cons; // 初始化信号量和互斥锁 sem_init(&empty_slots, 0, MAX_ITEMS); // 初始化空槽位信号量 sem_init(&full_slots, 0, 0); // 初始化已占用槽位信号量 pthread_mutex_init(&mutex, NULL); // 创建生产者和消费者线程 pthread_create(&prod, NULL, producer, NULL); pthread_create(&cons, NULL, consumer, NULL); // 等待线程结束 pthread_join(prod, NULL); pthread_join(cons, NULL); // 销毁信号量和互斥锁 sem_destroy(&empty_slots); sem_destroy(&full_slots); pthread_mutex_destroy(&mutex); return 0; }
2)伪唤醒状态
伪唤醒
是多线程编程中可能遇到的一个问题,尤其是在使用条件变量(
pthread_cond_t
)时。伪唤醒指的是线程在没有收到信号(如
pthread_cond_signal
或
pthread_cond_broadcast
)的情况下被唤醒,即条件变量的等待线程被意外唤醒,但实际上条件并没有满足。
1. 伪唤醒的原因
伪唤醒通常是由于操作系统调度、线程的内部状态更新或底层线程库的实现细节等原因引起的。具体原因因平台和线程库实现的不同而有所差异,但通常来说:
- 操作系统的线程调度器 :可能会唤醒一个等待线程,而没有通知线程它应该继续执行。这是因为操作系统将线程从“等待”状态转移到“就绪”状态,但并没有确定此时条件已经满足,因此线程被唤醒时可能并不应继续执行。
- 条件变量的实现 :某些条件变量的实现可能会导致线程在一些边界情况或时机上被唤醒,而此时它们并不应该继续执行。比如,当一个线程等待某个条件时,虽然条件没有满足,但它依然会被操作系统调度器唤醒。
- 并发环境的复杂性 :线程在等待时,可能会因为某些内部的同步机制或调度优先级的变化,产生意外的唤醒,这些唤醒并不总是因为条件已满足,而是由于线程调度机制的某些策略。
2. 伪唤醒的影响
如果线程在没有满足条件的情况下被唤醒,它可能会继续执行错误的代码,从而引发不一致的行为、逻辑错误或资源浪费。
- 程序逻辑错误 :当线程被唤醒时,它可能没有完成原本等待的条件检查,导致执行错误的操作。比如,在生产者-消费者问题中,消费者线程可能会在缓冲区为空时被伪唤醒,这时它会尝试消费数据,导致错误或崩溃。
- 死锁或无效循环 :伪唤醒可能导致线程进入错误的状态,甚至可能陷入死锁或无效循环。例如,如果消费者线程被伪唤醒,但缓冲区依然为空,它可能会继续等待并反复调用等待操作,导致不必要的资源浪费和死锁风险。
- 性能浪费 :伪唤醒会导致线程无意义地执行不必要的操作,从而浪费 CPU 资源和系统资源,特别是在高并发环境下,可能会显著影响程序性能。
- 基于伪唤醒状态,需要在唤醒判断时使用while循环判断。
3)线程池
线程池是一种用来管理和复用线程的设计模式,它通常用于处理大量短时间的任务。在这种模式下,线程池内会维护一组预先创建的线程,任务提交给线程池后由线程池中的线程处理。线程池避免了每次任务执行时都需要创建和销毁线程的开销,提高了性能。
1. 线程池的工作原理
线程池的基本工作原理可以分为以下几个步骤:
- 初始化线程池 :线程池在创建时会初始化一个线程池的大小(即线程数量),这些线程通常会在后台一直存在,等待任务到来。
- 提交任务 :当有任务需要执行时,任务被提交到线程池中。任务可以是函数、对象或其他类型的可执行单元。
- 线程处理任务 :线程池中的空闲线程从任务队列中获取任务并执行。执行完任务后,线程返回线程池,继续等待下一个任务。
- 任务完成 :任务完成后,线程池中的线程继续等待任务的到来,直到线程池销毁或不再需要工作。
2. 线程池的优势
- 减少线程创建销毁的开销 :每次创建和销毁线程是非常昂贵的操作。使用线程池可以重用已有的线程,减少创建和销毁线程的开销。
- 提高响应速度 :当有任务到来时,线程池中的线程可以立即开始执行任务,避免了等待线程创建的时间。
- 线程复用 :通过线程池的管理,系统可以控制线程的数量,避免过多线程导致的资源竞争和系统过载。
- 任务调度优化 :线程池可以根据系统负载和任务优先级进行调度,合理分配系统资源,提高任务处理效率。
3. 线程池的组成部分
一个完整的线程池通常包含以下几个部分:
- 工作线程 :线程池中的线程,负责从任务队列中取出任务并执行。
- 任务队列 :存储待处理任务的队列,通常采用线程安全的数据结构。
- 任务调度器 :线程池中的调度模块,负责调度空闲线程去执行任务。
- 线程池管理器 :负责线程池的创建、销毁和线程的管理。
4. 线程池的设计模式
线程池的设计模式常见的有以下几种类型:
- 固定大小的线程池 :线程池中的线程数是固定的,不会动态调整。适用于任务量相对稳定且需要固定资源的场景。
- 可伸缩的线程池 :线程池会根据任务的数量动态调整线程数,增加线程数以应对更多任务,减少线程数以节约资源。
- 单线程线程池 :线程池中只有一个线程,适用于任务必须串行执行的场景。
- 缓存线程池 :当任务量较少时,线程池中的线程数量可能为零,只有在任务到来时,线程池才会创建新线程。
4)其他锁
1. 悲观锁
悲观锁 假设在并发环境下,数据在读取和更新过程中很可能会发生冲突,因此在每次操作数据之前,都主动加锁以确保数据的安全。主要特点包括:
主动加锁 :每次取数据或更新数据前,都会先对数据加锁(如读锁、写锁、行锁等),确保操作期间数据不会被其他线程修改。
阻塞机制 :当其他线程试图访问被悲观锁保护的数据时,因锁已加持,其他线程会被阻塞挂起,直到锁被释放。
适用场景 :适用于写操作频繁、数据竞争激烈的场景,因为它通过加锁机制保证了数据的一致性和正确性。
示例应用 :在数据库中,为了保证数据在更新时不会发生冲突,通常使用悲观锁来防止其他事务同时修改数据。
2. 乐观锁
乐观锁 在读取数据时不会加锁,而是假设数据不会发生冲突。在更新数据前,再判断在此期间数据是否被其他线程修改过。如果发生了修改,则更新操作会失败,通常需要重试。乐观锁主要采用两种方式:
- 版本号机制 :在数据中维护一个版本号,每次读取数据时,同时读取版本号。在更新数据时,检查当前数据的版本号是否与读取时相同;若相同,则更新数据并递增版本号,否则更新失败,需要重试。
- CAS 操作(Compare-And-Swap) :CAS 是一种原子操作,它会将内存中的当前值与预期值进行比较,如果相等,则将新值写入内存;否则更新失败。CAS 操作通常以自旋的方式进行重试,直到成功为止。
优点 :
- 避免了传统锁的阻塞开销,适用于冲突较少的场景。
- 能够提高并发性能,因为线程在大部分情况下不需要加锁。
缺点 :
- 当并发冲突频繁时,会导致大量重试,自旋浪费 CPU 资源。
- 实现较为复杂,特别是在数据一致性方面需要精细控制。
3. 自旋锁(Spin Lock)
自旋锁 是一种特殊的锁,它在获取锁失败时不会挂起线程,而是让线程在一个循环中不断“忙等待”,直到锁被释放。自旋锁的特点包括:
- 忙等待 :线程在等待锁时持续循环检查锁的状态,而不进入阻塞状态。
- 适用于短临界区 :当临界区执行时间很短时,自旋锁可以避免线程挂起和唤醒所带来的上下文切换开销,从而提高性能。
- 缺点 :如果持锁时间较长,忙等待会浪费大量 CPU 时间,并可能导致资源浪费。
- 应用场景 :在多核系统中,对于非常短小且频繁的临界区,自旋锁可以提高并发性能。
4. 公平锁与非公平锁
在锁的设计中,公平性是一个重要考量。锁的公平性决定了多个线程争夺锁时,是否按照请求的顺序依次获得锁。
- 公平锁(Fair Lock)
:
- 顺序性 :公平锁按照线程请求锁的顺序分配锁,保证先请求的线程先获得锁。
- 优点 :避免了线程饥饿现象,每个线程都有机会获得锁。
- 缺点 :实现公平性通常需要额外的调度开销,在高竞争情况下可能降低吞吐量。
- 非公平锁(Unfair Lock)
:
- 抢占性 :非公平锁允许后请求的线程在某些情况下“插队”获取锁,未必按照严格的顺序分配。
- 优点 :通常具有更高的吞吐量和更低的延迟,因为它允许更高效地利用系统资源。
- 缺点 :可能导致部分线程长时间等待(饥饿问题),因为锁的分配没有严格的顺序保障。
5. 读写锁
读写锁 (Read-Write Lock)是一种允许多个线程并行读取共享资源,但在写操作时,只允许一个线程修改共享资源的同步机制。读写锁通过区分读操作和写操作来提高系统的并发性,尤其在读操作远多于写操作的情况下,能够显著提高性能。
基本原理
- 读模式 :多个线程可以同时读数据,只要没有线程在进行写操作。即,多个线程可以同时拥有 读锁 。
- 写模式 :写操作是独占的,只有一个线程可以对共享资源进行写操作,并且在写锁持有期间,其他线程无法进行任何读操作或写操作。
工作方式
- 多个线程可以同时持有读锁 :当没有线程在写数据时,多个线程可以并行地读取数据,这能够提高系统的读操作性能。
- 写锁是互斥的 :在任何线程持有写锁时,其他线程不能获得读锁或写锁。写锁确保数据的一致性和完整性。
5)STL、智能指针与线程安全
在现代 C++ 编程中,标准模板库(STL)、智能指针和线程安全是重要的概念,尤其是在多线程环境下,它们共同决定了代码的健壮性、性能以及安全性。下面,我们来讨论一下这三者之间的关系以及它们如何影响多线程编程。
1. STL与线程安全
STL(Standard Template Library)是 C++ 标准库中的一部分,提供了一系列常用的容器、算法和迭代器等。它的设计初衷是提供一个高效、可重用的编程工具。然而, STL本身并不保证线程安全 。换句话说,在多线程环境中,多个线程同时访问或修改同一个 STL 容器时,会导致数据竞争和不可预期的行为。
STL中的线程安全问题
- 不支持并发修改 :多个线程同时对同一个 STL 容器进行修改(如插入、删除元素等)时,会导致数据损坏。为了保证线程安全,需要显式地使用锁(如互斥锁)来同步对容器的访问。
- 只支持线程安全的迭代 :在多个线程对同一个容器进行只读操作时(无修改),大多数 STL 容器是安全的。然而,如果一个线程正在修改容器,而另一个线程正在读取,仍然可能发生竞态条件。
- 并发容器
:C++11 引入了一些线程安全的容器,如
std::vector
和std::list
等,并没有内置的并发安全设计,但可以通过加锁来确保线程安全。C++17 中引入了更高效的并发数据结构,如std::shared_mutex
来实现共享锁。
线程安全的使用方式
为了让 STL 容器在多线程环境中安全使用,通常采取以下策略:
- 使用互斥锁(
std::mutex
)保护容器 :所有访问容器的操作(无论是读取还是修改)都必须加锁,确保每次只有一个线程能操作容器。 - 使用读写锁
:在大多数情况下,如果只有少量线程需要修改容器而大多数线程只是读取容器,可以使用
std::shared_mutex
或std::shared_lock
来提高性能。
- 使用互斥锁(
2. 智能指针与线程安全
智能指针(
std::unique_ptr
、
std::shared_ptr
、
std::weak_ptr
)是 C++11 引入的用于自动管理动态分配内存的工具。它们通过 RAII(资源获取即初始化)机制帮助开发者减少内存泄漏、空悬指针等问题的出现。智能指针本身有着一定的线程安全特性,但也存在一些需要注意的问题。
std::unique_ptr
:- 线程不安全
:
std::unique_ptr
是一个独占所有权的智能指针,意味着在同一时刻,只有一个unique_ptr
拥有对对象的所有权。因此,std::unique_ptr
本身是线程不安全的,不能被多个线程共享。 - 正确的做法
:如果需要在多线程环境中共享
std::unique_ptr
,可以使用std::move
转移所有权,但只能在同一个线程中操作一次,避免多线程同时访问。
- 线程不安全
:
std::shared_ptr
:- 线程安全的引用计数
:
std::shared_ptr
是一个智能指针,允许多个shared_ptr
实例共享同一个对象。它使用引用计数来管理对象的生命周期,C++ 标准保证引用计数的更新是线程安全的。 - 线程安全的限制
:虽然引用计数本身是线程安全的,但多个线程同时访问同一个对象时,若对象本身不是线程安全的,就可能会引发数据竞争问题。
std::shared_ptr
本身并不保证对象的线程安全,必须保证对象在多个线程中安全访问。
- 线程安全的引用计数
:
std::weak_ptr
:- 不具有所有权
:
std::weak_ptr
是一个弱引用,不会影响对象的引用计数,因此不需要考虑线程安全问题。但是,std::weak_ptr
的lock()
方法返回一个shared_ptr
,它需要保证shared_ptr
的线程安全。
- 不具有所有权
:
3. 智能指针与线程安全的结合
- 避免共享
unique_ptr
:std::unique_ptr
不应在多个线程间共享。如果需要在多个线程间传递对象的所有权,应通过std::move
转移所有权。 - 使用
shared_ptr
时的保护 :std::shared_ptr
的引用计数是线程安全的,但如果多个线程访问共享的对象本身,必须保证对象的访问是线程安全的。可以通过加锁或使用其他线程同步机制来确保对象的线程安全。 - 避免同时使用多个智能指针
:尽量避免在多个线程中使用同一个对象的多个
shared_ptr
,因为虽然引用计数是线程安全的,但多个线程操作同一对象时,仍然可能导致数据竞争。
6)单例模式
单例模式 (Singleton Pattern)是一种常见的设计模式,其主要目的是确保一个类只有一个实例,并且提供一个全局访问点来获取该实例。单例模式常用于系统中需要唯一实例的场景,例如数据库连接、配置管理器等。
1. 单例模式的特点
- 唯一性 :单例类只能有一个实例。
- 全局访问 :提供一个全局的访问点来获取实例。
- 懒加载 :通常单例实例在第一次使用时才被创建,而不是在程序启动时创建。
- 线程安全 :在多线程环境下,确保实例的创建是线程安全的。
2. 单例模式的优缺点
优点 :
- 节省内存 :通过确保系统中只存在一个实例,避免了多次创建和销毁相同对象所带来的内存浪费。
- 全局访问点 :单例模式提供了一个全局访问点,可以方便地获取到唯一的实例,避免了全局变量的使用。
- 惰性初始化 :单例实例在第一次使用时才创建,可以有效节省启动时的资源消耗。
缺点 :
- 隐藏依赖关系 :由于单例模式提供了全局访问点,这可能导致代码中隐式的依赖关系,难以进行测试和维护。
- 难以扩展 :如果需要扩展单例类或实例化多个实例,单例模式就不再适用。
- 线程安全问题 :在多线程环境下,单例实例的创建需要额外的线程同步机制,否则可能会出现竞争条件和多次创建实例的问题。
3. 单例模式的使用场景
- 配置管理器 :例如,程序读取配置信息时,只需要一个全局配置管理实例,避免频繁读取和修改配置。
- 日志系统 :程序中的日志记录通常使用单例模式来保证日志记录的唯一性。
- 数据库连接池 :数据库连接池通常使用单例模式来保证池中数据库连接的唯一性和共享。
- 线程池 :线程池的管理通常也会使用单例模式,确保全局只有一个线程池实例。
4. 单例模式设计方式
饿汉式
饿汉式 单例模式在类加载时就创建了单例对象,而不是等到第一次使用时才创建。这个方法简单且直接,通常不需要考虑线程安全问题,因为类加载的过程是线程安全的。
template <typename T> class Singleton { static T data; public: static T* GetInstance() { return &data; } };
- 实例化时机 :在类加载时创建实例。
- 线程安全 :因为类加载过程是线程安全的,实例化过程无需加锁。
- 缺点 :即使实例可能不会被使用,类加载时就已经创建了实例,这会浪费资源。
懒汉式
懒汉式 单例模式采用延迟实例化的策略,只有在第一次使用单例对象时才创建它。这种方法在创建对象之前不会占用任何资源,适合资源消耗较大的对象。
template <typename T> class Singleton { static T* inst; public: static T* GetInstance() { if (inst == NULL) { inst = new T(); } return inst; } };
- 实例化时机 :只有在需要使用实例时才进行创建。
- 线程安全问题 :在多线程环境下,需要特别注意线程安全问题。