目录
🍊易编橙🍊终身成长社群:易编橙·终身成长社群——IT之路不再迷茫-CSDN博客
个人主页:东洛的克莱斯韦克-CSDN博客
线程概述
概念阐述
进程是分配系统资源的实体,所分配的资源包括但不限于地址空间(可以把进程地址空间理解为线程分配进程资源的窗口),代码和数据,IO管道,内核数据,物理内存。也可以理解为资源的所有权是进程的。
线程是瓜分进程资源的执行流,也是CPU基本的调度单元。线程作为执行流也是进程的一种资源。
每个线程都包含有表示执行环境所必需的信息,其中包括进程中标识线程的线程ID、一组寄,存器值、栈、调度优先级和策略、信号屏蔽字、errno变量以及线程私有数据。一个进程的所有信息对该进程的所有线程都是共享的,包括可执行程序的代码、程序的全局内存和堆内存、栈以及文件描述符。
线程通信
同一个进程下的线程间通信是比进程间通信容易
由于共享地址空间,线程间的通信是十分容易的,当一个线程改变了内存中的一个数据项时,其他线程在访问这一数据项时能够看到变化后的结果。如果一个线程以读权限打开一个文件,那么同一个进程中的其他线程也能够从这个文件中读取数据。
进程间的数据是隔离开的,进程间通信往往需要OS介入,并且需要额外的公共资源作为通信的场所(通信的基础)【linux】进程间通信,多进程并不适合并发的场景。与之相对的同一个进程内的线程通信几乎是零成本,多线程之间的协作往往灵活,高效。
同一个进程内的线程在地址空间上很多资源都是共享的,多线程竞争资源会导致数据安全问题,由于并发导致的线程安全问题就需要加锁,条件变量等等解决方案使线程达到同步与互斥的效果,从而保证数据的安全性,正确性。
生命周期
线程的生命周期对系统的压力往往比进程小
创建一个线程往往比创建一个进程花费的时间少。线程分配的进程的资源,这些资源已经存在,OS只需创建线程的控制块即可,而进程需要操作系统创建各种内核数据结构。
同一进程内的线程间切换要比进程间切换花费的时间少。虽然具体的上下文切换时间会因操作系统、硬件平台以及系统负载等因素而有所不同,但一般来说,线程间切换的时间要比进程间切换的时间短。一些实验数据显示,平均每次线程上下文切换耗时可能在几微秒(μs)左右,而进程上下文切换的耗时则可能更长。
终止一个线程往往比终止一个进程花费的时间少。这和创建是类似的,线程的销毁只需要释放掉线程的控制块即可,往往不需要释放资源,因为进程才是资源的所有者。
多线程与单处理器
在单处理器系统上运行多线程程序,虽然所有的线程实际上是在同一时间片内通过时间片轮转的方式被处理器顺序执行的,但多线程编程模型仍然带来了显著的好处。
多线程允许开发者将复杂的任务分解成多个可以并行(或看似并行)执行的小任务,每个任务由一个线程来处理。这样做可以使程序的结构更加清晰,更易于理解和维护。
在单处理器上多线程不能真正并行执行,但线程的切换允许在等待I/O操作(磁盘读写、网络请求等)完成的线程被阻塞时,其他线程可以继续执行。这样,程序的总体响应时间和吞吐量就可以得到提高,因为系统资源得到了更有效的利用。
在多线程程序中,不同的线程可以访问和共享程序中的资源(如内存、文件描述符等),这有助于更有效地利用系统资源。例如,一个线程可以处理用户输入,而另一个线程可以同时处理后台数据计算,可以参考生产者消费者模型,本文也会细讲。
单处理器系统不能真正并行执行多个线程,但多线程编程模型可以模拟并行处理的效果。这对于需要处理大量数据或复杂计算的应用程序来说是非常有用的。多线程编程模型提供了丰富的同步和通信机制(如互斥锁、条件变量、信号量等),这些机制可以帮助开发者控制线程之间的执行顺序和数据共享,从而避免数据竞争和死锁等问题。
线程的内核观点
Linux的工程师在设计线程时复用了大量进程的代码。并没有为线程描述属性,配套相关算法,处理复杂的关系等等。
Linux并没有在内核中明确区分线程和进程,而是统一使用task_struct
结构体来表示,这个结构体包含了线程(或进程)的所有信息。Linux的这种设计使得它在支持传统UNIX进程模型的同时,也能够很好地支持线程模型,从而提高了与现有UNIX应用程序的兼容性。
在Linux内核中并没真正线程的概念,可以形象的理解为轻量化进程。
它的轻量化体现在,该轻量化进程会直接共享进程的内核数据(文件描述符,信号内核数据等等),也会共享地址空间以及通过页表映射的物理内存空间等等,OS只需创建类似于PCB进程控制块来管理该执行流(轻量化进程)即可。
也就是,轻量化进程在被创建时不需要再占用系统资源,它要占用的是进程的部分资源。
对进程代码的复用使linux内核更为简洁,其健壮性和可靠性也远远优于其他的一些系统。
用户级线程和内核级线程
内核中并没有线程的概念,只有轻量级进程,所以系统调用的接口中只能创建轻量级进程。
clone()
系统调用允许创建一个轻量级进程,同时允许调用者指定新进程与原进程之间需要共享哪些资源。这些资源可以包括内存空间、文件描述符、信号处理器等。通过仔细选择 clone()
的参数,开发者可以实现高效的资源共享和协作。
#include <sched.h>
#include <signal.h>
#include <unistd.h>
int clone(int (*fn)(void *), void *child_stack, int flags, void *arg, ...);
fn
:指向新进程要执行的函数的指针。child_stack
:新进程的堆栈地址,必须是独立的,不能与原进程共享。flags
:用于设置新进程的属性,如是否共享内存、文件描述符等。这些属性通过位或(OR)操作组合而成。arg
:传递给新进程的参数。...
:在某些实现中,clone()
可能还接受额外的参数,但通常这些参数不是必需的。
clone()
的 flags
参数非常关键,它决定了新进程与原进程之间的资源共享情况。以下是一些常用的 flags
值:
CLONE_VM
:共享内存空间。CLONE_FS
:共享文件系统信息。CLONE_FILES
:共享文件描述符表。CLONE_SIGHAND
:共享信号处理器表。CLONE_THREAD
:在同一个线程组中创建线程(而不是进程)。CLONE_PARENT
:设置子进程的父进程ID为调用者的父进程ID(而不是调用者本身)。CLONE_NEWPID
:在新的PID命名空间中创建进程(用于容器技术)。
线程库
为了在用户层有线程的概念,几乎所有的Linux平台都有第三方线程库,线程库是动态库【linux】详解——库,它在用户层为我们维护起来线程的概念。线程库给我们提供了一些API(应用程序接口),我们可以通过调用这些API来创建,销毁,管理线程。下面是一些常见的线程库。
Pthread
Pthread(POSIX Threads)是Linux上最常用且最有名的线程库之一,它遵循POSIX标准,提供了丰富的线程管理功能。Pthread是一个在用户层实现的线程库,它封装了Linux底层的线程创建和管理机制,降低了开发者直接与系统调用打交道的复杂度。Pthread提供了线程创建(pthread_create)、线程终止(pthread_exit、pthread_cancel)、线程等待(pthread_join)、线程同步(互斥锁、条件变量等)等一系列功能。在使用Pthread时,需要在编译时链接pthread库,通常是通过在gcc/g++命令中添加-lpthread选项来实现。
Threads
Threads是另一个在Linux平台上广泛使用的线程库。虽然其知名度和普及程度可能略逊Pthread,但它同样提供了丰富的线程管理功能。Threads线程库的一个显著特点是,它允许线程实体与核心轻量级进程相对应,且线程之间的管理在核外函数库中实现,这使得Threads具有更强的管理线程的能力。
NPTL
NPTL是Linux上现代模式运行的线程库,它自Linux内核2.6版本开始被广泛采用。NPTL实现了真正的内核线程,摒弃了管理线程的概念,使得线程的创建、终止和回收等工作可以由内核直接完成。NPTL线程库的优势在于其高效性和对多处理器系统的良好支持。由于线程直接由内核管理,因此可以充分利用多处理器的并行处理能力,提高程序的执行效率。
Linux平台上的第三方线程库以Pthread和NPTL最为知名和常用。Pthread作为POSIX标准的实现,提供了丰富的线程管理功能,并且得到了广泛的支持和应用。而NPTL则作为现代Linux系统上的线程库,以其高效性和对多处理器系统的良好支持而受到青睐。
库维护的一些线程属性
库中的一些描述线程的字段可以参考如下表格
类别 | 描述 | 关键点 |
---|---|---|
线程ID(Thread ID) | 唯一标识一个线程 | 类型:pthread_t <br>- 分配:由pthread_create() 创建时分配<br>- 用途:用于后续线程操作,如pthread_join() 、pthread_detach() |
线程栈(Thread Stack) | 线程的工作区域 | 分配:线程创建时分配,大小可设置<br>- 用途:存储局部变量、函数调用参数等<br>- 释放:线程终止时自动释放 |
线程状态(Thread State) | 表示线程当前的状态 | 状态类型:运行、就绪、阻塞、终止等<br>- 管理:由操作系统和线程库共同管理 |
线程优先级(Thread Priority) | 线程的执行优先级 | 设置:通过pthread_setschedparam() 设置<br>- 作用:影响操作系统调度决策 |
线程属性(Thread Attributes) | 线程的多个配置选项 | 表示:pthread_attr_t 结构体<br>- 设置:通过pthread_attr_set*() 函数设置<br>- 用途:控制线程行为,如栈大小、分离状态、调度策略等 |
线程回调函数(Thread Callback Functions) | 线程的入口点 | 定义:线程执行时调用的函数<br>- 参数:创建线程时指定<br>- 执行:线程被调度时执行 |
线程上下文(Thread Context) | 线程执行时的环境状态 | 内容:CPU寄存器状态、内存映射等<br>- 保存与恢复:由操作系统和线程库共同管理 |
信号屏蔽掩码(Signal Mask) | 定义线程阻塞的信号 | 设置:通过pthread_sigmask() 设置<br>- 作用:实现信号的独立处理 |
错误码(Error Code) | 表示线程操作中的错误 | 表示:通过返回值或全局变量(如errno )表示<br>- 查询:通过线程库函数或检查返回值查询<br>- 处理:根据错误类型采取相应措施 |
优缺点
线程库是在用户层搭建的库,我们可以以此区分出用户级线程和内核级线程,内核级线程可以称为内核支持的线程或轻量级进程。
从概念上很好区分用户级线程和内核级线程,下面阐述一下优缺点~
用户级线程的优点:同一个进程内的用户级线程共享同一个地址空间。与线程相关的所有控制信息和状态(如线程上下文、栈指针、寄存器等)都被存储在用户空间的内存区域中,而不是内核空间。通常,在操作系统中,线程切换是一个涉及状态保存、恢复和调度的复杂过程。在某些情况下,这个过程可能需要进入内核态(也称为特权态)来完成,因为涉及到对系统资源的访问和控制。然而,由于所有线程管理数据结构都位于用户空间,线程切换可以在用户态下直接完成,而无需进入内核态。每次从用户态切换到内核态,以及从内核态返回用户态,都需要执行一系列的操作,如保存和恢复寄存器状态、切换内存地址空间等。这些操作需要消耗CPU时间和系统资源,并且可能导致性能瓶颈。由于用户级线程切换不需要内核态特权,因此节省了这些状态转换的开销。(这里说的内核态和用户态是CUP的工作模式)这种设计带来的主要好处是提高了程序的效率和减少了系统资源的消耗。因为避免了不必要的状态转换,CPU可以更快地执行线程切换,从而提高整体的系统吞吐量。同时,由于减少了内核的介入,也降低了内核的负担,使得系统能够更好地应对高并发和复杂任务。
用户级线程的缺点:在多对一的用户级线程模型中(多线程对单进程)由于所有用户线程共享一个内核调度实体,当一个线程阻塞时,由于内核不知道这个进程内部有多个线程,因此它会将整个进程(即所有的用户级线程)视为阻塞状态,直到系统调用完成。
内核一次只能把一个进程分配给一个从处理器,在基于时间片轮转的CPU上,进程得到的时间片(通俗的讲就是在CPU上运行的时间)一般会平均分配给进程内的线程,CPU一次也只能调度一个线程,所以说没有真正意义的并发,但在该时间片内所有线程的任务都会得到推进。如果想实现真正意义的并发,可以写成多进程程序,但进程间的切换和通信相比于线程效率低下,额外资源的开销大。
站在内核的角度,内核并不能感知到用户级线程,但内核知道自己养了多少线程,也知道自己每个线程是干什么的,内核完全有能力把不同的线程分派到不同的处理器上,也就可以实现真正的并发。
线程的创建
可以使用POSIX线程(pthread)库来创建线程。主要使用的函数是pthread_create
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
pthread_t *thread
:指向线程标识符的指针,用于唯一标识创建的线程。const pthread_attr_t *attr
:指向线程属性的指针,可以设置为NULL以使用默认属性。void *(*start_routine) (void *)
:新线程将执行的函数,该函数必须返回void *类型,并接受一个void *类型的参数。void *arg
:传递给start_routine
函数的参数,如果不需要传递参数,则设置为NULL。
- 成功时返回0,出错时返回错误码。
下面是一个双死循环实例,一份代码可以同时执行两个死循环
#include <pthread.h>
#include <stdio.h>
#include<time.h>
#include <unistd.h>
void * test(void*) //线程要执行的方法
{
while (true)
{
printf("我是一个线程\n");
sleep(1);
}
}
int main()
{
pthread_t thread; //线程
pthread_create(&thread, nullptr, test ,nullptr); //创建线程
while (true)
{
printf("我是一个主线程\n");
sleep(1);
}
return 0;
}
g++编译要指定库名字,可以用Ctrl + C杀掉进程
线程ID
ID比较函数
pthread_equal
是POSIX线程(pthread)库中的一个函数,用于比较两个线程标识符(pthread_t
类型)是否相等。这个函数在多线程编程中非常有用,因为它提供了一种机制来检查两个线程标识符是否指向同一个线程。由于线程标识符(pthread_t
)可能是一个复杂的数据结构或指针,直接比较它们可能不总是安全的或有意义的。因此,pthread_equal
函数提供了一种标准化的、可移植的方式来执行这种比较。
#include<pthread.h>
int pthread_equal(pthread_t tidl, pthread_t tid2);
pthread_t tidl
:第一个线程标识符。pthread_t tid2
:第二个线程标识符。如果tidl
和tid2
表示的是同一个线程,则返回非零值(通常是1,但具体的非零值由实现定义,不应被用作任何具体的值)。如果tidl
和tid2
表示的不是同一个线程,则返回0。
ID获取函数
pthread_t pthread_self(void);
函数pthread_self(void);
不接受任何参数(由void
指示),并返回一个pthread_t
类型的值,这个值就是当前调用线程的线程ID。线程ID是在线程创建时由系统分配的,是唯一的,用于在后续的操作中引用和区分不同的线程。
如下代码是个测试实例,测试pthread_create函数返还给我们的线程ID和线程自己获取到的是否一样
#include <pthread.h>
#include <stdio.h>
#include <time.h>
#include <unistd.h>
pthread_t thread; // 线程ID
void *test(void *) // 线程要执行的方法
{
int i = pthread_equal( pthread_self(),thread);
if (0 == i)
{
printf ("这两个ID不是同一个~\n");
}
else
{
printf ("这两个ID是同一个~\n");
}
}
int main()
{
pthread_create(&thread, nullptr, test, nullptr); // 创建线程
sleep(2);
return 0;
}
结果如下
如果我们以地址的形式打印一下线程ID会是什么值呢
#include <pthread.h>
#include <stdio.h>
#include <time.h>
#include <unistd.h>
pthread_t thread; // 线程ID
void *test(void *) // 线程要执行的方法
{
}
int main()
{
pthread_create(&thread, nullptr, test, nullptr); // 创建线程
printf("%p", thread);
return 0;
}
这个地址在进程地址空间中是共享区。线程库是动态库,动态库通过页表映射到地址空间的共享区的位置【Linux】对共享库加载问题的深入理解,线程ID表示在线程库中描述该线程属性相关字段的起始位置。
这里要强调的是,主线程的栈在地址空间的栈空间上,其他线程的栈在共享区——动态库会通过页表挂接到共享区,线程库中有描述的线程栈属性的字段。也就是说每个线程都有单独的栈结构。
线程的终止
pthread_exit
如果进程中的任意线程调用了exit、_Exit或者_exit,那么整个进程就会终止。与此相,类似,如果默认的动作是终止进程,那么,发送到线程的信号就会终止整个进程。
单个线程可以通过3种方式退出,可以在不终止整个进程的情况下停止它的控制流。
线程可以简单地从启动例程中返回,返回值是线程的退出码 |
线程可以被同一进程中的其他线程取消 |
线程调用pthread_exit |
void pthread_exit(void *rval_ptr);
rval_ptr
: 这是一个void
类型的指针,它用于向其他线程提供一个返回值。这个返回值可以被那些调用pthread_join()
等待该线程结束的线程所访问。rval_ptr
可以指向任何类型的数据,但通常用来传递一个状态码或指向一个更复杂的数据结构的指针。
调用pthread_exit()
的线程会立即停止执行,但在线程完全终止之前,系统会释放该线程所拥有的所有资源(如线程栈)。
如果线程是通过调用pthread_create()
创建的,并且没有分离(detach),那么其他线程可以通过调用pthread_join()
来等待这个线程的结束,并通过pthread_join()
的第二个参数来获取pthread_exit()
中传递的返回值。
pthread_cancel
#include <pthread.h>
int pthread_cancel(pthread_t thread);
这个函数接受一个 pthread_t
类型的参数,该参数是目标线程的标识符(ID),该标识符是在调用 pthread_create
函数创建线程时返回的。如果函数调用成功,它将返回 0
;如果发生错误,则返回错误码。
调用 pthread_cancel
函数将向指定的线程发送一个取消请求。然而,这并不意味着线程会立即终止。线程的实际取消行为取决于它是否设置了取消状态(通过 pthread_setcancelstate
)和取消类型(通过 pthread_setcanceltype
),以及它是否执行了可以被取消的点(cancellation points)。
取消状态可以是 PTHREAD_CANCEL_ENABLE
(允许取消)或 PTHREAD_CANCEL_DISABLE
(禁用取消)。取消类型可以是 PTHREAD_CANCEL_DEFERRED
(延迟取消,直到线程到达取消点)或 PTHREAD_CANCEL_ASYNCHRONOUS
(异步取消,可以在任何时间取消线程,但这通常不是由 POSIX 线程库实现的,因为它需要操作系统的支持)。
线程可以通过调用 pthread_testcancel
函数来检查是否有取消请求,并且该函数也是一个取消点。但是,并非所有库函数都是取消点。通常,只有那些可以被长时间阻塞的函数(如 I/O 操作、等待条件变量等)才是取消点。
需要注意的是,即使 pthread_cancel
被调用,目标线程也可能不会立即终止,特别是如果它处于不可取消的状态或正在执行不可取消的代码段。此外,如果线程已经退出(即已经调用了 pthread_exit
或从线程函数返回),则对该线程调用 pthread_cancel
将没有效果。
最后,需要强调的是,线程取消是一种协作机制,它依赖于线程代码本身对取消请求的响应。因此,在设计使用 pthread_cancel
的程序时,需要仔细考虑线程的取消策略和清理逻辑。
下面是代码示例,thread_function
是一个无限循环,它每秒打印一次消息,并在每次迭代结束时调用 pthread_testcancel()
来检查是否有取消请求。主线程在创建线程后等待5秒,然后尝试取消该线程。如果取消成功,pthread_join
将返回,并且我们可以知道线程已经被取消。
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
// 线程将要执行的函数
void* thread_function(void* arg) {
// 无限循环,直到被取消
while (1) {
// 在这里执行一些任务
printf("Thread is running...\n");
sleep(1); // 模拟耗时操作
// 检查是否有取消请求
pthread_testcancel();
// 实际上,对于简单的示例,我们可能不需要显式调用 pthread_testcancel(),
// 因为循环的每次迭代都可以视为一个“隐式”的取消点(尽管这不是严格的取消点)。
// 但为了演示目的,我们还是显式地调用了它。
}
// 注意:这个 return 语句实际上永远不会被执行,因为线程将被取消。
// 但如果我们有退出循环的其他方式(比如条件变量),则可能需要这个 return。
return NULL;
}
int main() {
pthread_t thread_id;
int ret;
// 创建一个新线程
ret = pthread_create(&thread_id, NULL, thread_function, NULL);
if (ret != 0) {
printf("pthread_create() failed\n");
exit(EXIT_FAILURE);
}
// 让主线程等待一段时间
sleep(5); // 假设我们想在5秒后取消线程
// 尝试取消线程
ret = pthread_cancel(thread_id);
if (ret != 0) {
printf("pthread_cancel() failed\n");
exit(EXIT_FAILURE);
}
// 等待线程终止(无论是正常终止还是被取消)
ret = pthread_join(thread_id, NULL);
if (ret != 0) {
printf("pthread_join() failed\n");
exit(EXIT_FAILURE);
}
printf("Thread has been canceled or exited\n");
return 0;
}
结果如下
pthread_join
int pthread_join(pthread_t thread, void **rval_ptr);
thread
: 这是要等待的线程的标识符,类型为pthread_t
。这个标识符是pthread_create()
函数在创建线程时返回的。
rval_ptr
: 如果thread线程通过return返回,value_ ptr所指向的单元里存放的是thread线程函数的返回值。
成功时,返回0
。出错时,返回一个错误编号。常见的错误包括传递了一个无效的线程标识符,或者试图对已经结束的线程调用pthread_join()
。
一旦一个线程被另一个线程通过pthread_join()
等待,该线程就不能被其他线程再次等待了。这是因为pthread_join()
会清理被等待线程的相关资源,包括它的线程标识符。
如果在调用pthread_join()
之前,目标线程已经结束,pthread_join()
仍然会成功返回,但此时rval_ptr
将指向由pthread_exit()
设置的返回值(如果提供了)。
如果线程是分离(detached)的,那么不能对它调用pthread_join()
。尝试这样做将导致错误。线程可以在创建时通过pthread_attr_setdetachstate()
设置为分离状态,或者通pthread_detach()
在创建后设置。
关于参数是无符号类型,C语言中没有模板的感念,参数是void void* 或 void ** 类型的是为了让接口泛型化,你甚至可以传一个对象的指针,或者说传入复杂结构的指针~
下面是代码实例,第一个线程将计算并打印一个简单数学问题的结果,第二个线程将模拟一些耗时操作(如等待一段时间)。主线程将等待这两个线程完成,并收集第一个线程的计算结果。
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h> // 用于sleep函数
// 定义一个结构体来存储计算结果
typedef struct {
int result;
} CalculationResult;
// 第一个线程函数,用于计算并存储结果
void *calculate_result(void *arg) {
int a = 10, b = 20;
CalculationResult *result = (CalculationResult *)malloc(sizeof(CalculationResult));
if (result == NULL) {
perror("Failed to allocate memory for result");
pthread_exit(NULL);
}
result->result = a + b; // 假设的计算操作
printf("Calculation thread: %d + %d = %d\n", a, b, result->result);
// 通过返回值传递结果
pthread_exit((void *)result);
}
// 第二个线程函数,模拟耗时操作
void *simulate_work(void *arg) {
printf("Work thread: Simulating work...\n");
sleep(2); // 等待2秒
printf("Work thread: Done simulating work.\n");
pthread_exit(NULL);
}
int main() {
pthread_t tid1, tid2;
void *thread_result;
CalculationResult *result;
// 创建第一个线程
if (pthread_create(&tid1, NULL, calculate_result, NULL) != 0) {
perror("Failed to create calculation thread");
return 1;
}
// 创建第二个线程
if (pthread_create(&tid2, NULL, simulate_work, NULL) != 0) {
perror("Failed to create work thread");
return 1;
}
// 等待第一个线程完成并获取结果
if (pthread_join(tid1, &thread_result) != 0) {
perror("Failed to join calculation thread");
return 1;
}
result = (CalculationResult *)thread_result;
printf("Main thread: Received result from calculation thread: %d\n", result->result);
free(result); // 释放之前分配的内存
// 等待第二个线程完成(尽管在这个例子中它不是必需的,因为主线程已经等待了第一个线程)
if (pthread_join(tid2, NULL) != 0) {
perror("Failed to join work thread");
return 1;
}
printf("Main thread: Both threads have finished.\n");
return 0;
}
线程分离
#include <pthread.h>
int pthread_detach(pthread_t *tid);
pthread_t *tid
,这是一个指向 pthread_t
类型的指针,pthread_t
是用于唯一标识线程的类型。这个参数指定了要更改为分离状态的线程。
函数执行成功后返回 0
;如果发生错误,则返回一个错误编号。具体的错误编号可在 pthread.h
头文件中找到,或者在 POSIX 线程的相关文档中查找。
线程分离指的是将一个线程的状态更改为“分离”状态。在POSIX线程中,默认情况下,创建的线程是非分离的,这意味着除非显式地调用pthread_join
函数等待该线程结束,否则程序中的其他线程(包括主线程)在退出时可能会阻塞,等待这些非分离线程结束。
当调用pthread_detach
函数并将一个线程的标识符(pthread_t 类型的值)作为参数传递给它时,该线程就被设置为分离状态。设置为分离状态的线程在终止时会自动释放其资源,而不需要其他线程对其进行pthread_join
调用。这样做的好处是可以避免资源泄露,尤其是当线程执行完成后其结果不再需要,或者当无法确保有线程会对它进行pthread_join
调用时。
线程分离是一种机制,它允许线程在结束时自动释放资源,而无需其他线程显式地进行清理工作。这是通过调用pthread_detach
函数实现的,该函数将一个线程设置为分离状态。
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
// 线程将要执行的函数
void* thread_function(void* arg) {
// 在这里执行一些任务
printf("Thread is running...\n");
sleep(5); // 模拟耗时操作
// 注意:由于线程被设置为分离状态,我们不需要在这里做特别的清理工作
// 线程正常结束
printf("Thread is exiting...\n");
return NULL;
}
int main() {
pthread_t thread_id;
int ret;
// 创建一个新线程
ret = pthread_create(&thread_id, NULL, thread_function, NULL);
if (ret != 0) {
printf("pthread_create() failed\n");
exit(EXIT_FAILURE);
}
// 将线程设置为分离状态
ret = pthread_detach(thread_id);
if (ret != 0) {
printf("pthread_detach() failed\n");
// 注意:如果pthread_detach失败,你可能需要处理这个错误,
// 比如记录日志或尝试其他恢复措施。但在这个简单的例子中,我们直接退出。
exit(EXIT_FAILURE);
}
// 主线程继续执行,不需要等待分离线程结束
printf("Main thread is continuing without waiting for the detached thread...\n");
// 让主线程等待一段时间,以便看到分离线程的输出(可选)
sleep(6);
printf("Main thread is exiting...\n");
// 注意:当主线程退出时,分离线程将继续执行,直到它完成。
// 但是,一旦主线程退出,整个进程将结束,因此分离线程的执行可能会被操作系统中断。
// 在实际应用程序中,应该确保所有重要的工作都在主线程退出之前完成。
return 0;
}
关于并发的一些专业语术
类别 | 描述 |
---|---|
死锁 | 两个或两个以上的进程因其中的每个进程都在等待其他进程做完某些事情而不能继续执行,这样的情形叫做死锁 |
活锁 | 两个或两个以上进程为了响应其他进程中的变化而持续改变自己的状态但不做有用的工作,这样的情形叫做活锁 |
互斥 | 当一个进程在临界区访问共享资源时,其他进程不能进入该临界区访问任何共享资源,这种情形叫做互斥 |
竞争条件 | 多个线程或者进程在读写一个共享数据时,结果依赖于它们执行的相对时间,这种情形叫做竞争条件 |
饥饿 | 是指一个可运行的进程尽管能继续执行,但被调度器无限期地忽视,而其他线程不能被调度执行的情况 |
原子操作 | 一个或多个指令的序列,对外是不可分的;即没有其他进程可以看到其中间状态或者中断此操作 |
临界区 | 是一段代码,在这段代码中进程将访问共享资源,当另外一个进程已经在这段代码中运行时,这个进程就不能在这段代码中执行 |
并发导致数据不一致问题
下面模拟抢票的逻辑,多线程并发的对一个整型变量做减减
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <vector>
#include <string>
int tickets = 100; // 门票数量
#define N_THREAD 4 // 线程数量
void *ExecuteFlow(void *arg)
{
while (true)
{
if (tickets > 0)
{
usleep(10000);
tickets--;
printf("%p抢到了,还剩%d门票\n", pthread_self(), tickets);
}
else
{
pthread_exit(nullptr);
}
usleep(13); //模拟抢票的后续动作消耗的时间
}
}
int main()
{
pthread_t thread[N_THREAD];
for (int i = 0; i < N_THREAD; i++)
{
pthread_create(&thread[i], nullptr, ExecuteFlow, nullptr); // 线程创建
}
for (int i = 0; i < N_THREAD; i++) // 主线程等待其他线程退出
{
pthread_join(thread[i], nullptr);
}
return 0;
}
结果如下
当门票为负数时,还有线程在抢,但我们代码中有 if (tickets > 0) 该判断语句,说明当有线程在进入 if (tickets > 0)代码块,准备抢票时,已经有线程把票抢完了,所以会出现负数的情况。
变量增减操作通常需要三步
1.把变量从内存读入寄存器
2.对变量修改
3.再把变量写回内存
线程在被调度到任何一步时都有可能被切走。并且其他线随时都有可能修改掉数据。通过这个例子我们可以很直观的感受到并发问题带来的数据不一致问题。
互斥量
概述
可以使用pthread的互斥接口来保护数据,确保同一时间只有一个线程访问数据。互斥量(mutex)从本质上说是一把锁,在访问共享资源前对互斥量进行设置(加锁),在访问完成后释,放(解锁)互斥量。
对互斥量进行加锁以后,任何其他试图再次对互斥量加锁的线程都会被阻塞,直到当前线程释放该互斥锁。如果释放互斥量时有一个以上的线程阻塞,那么所有该锁上的阻塞线程都会变成可运行状态,第一个变为运行的线程就可以对互斥量加锁,其他线程就会看到互斥量依然是锁着的,只能回去再次等待它重新变为可用。
在这种方式下,每次只有一个线程可以向前执行。只有将所有线程都设计成遵守相同数据访问规则的,互斥机制才能正常工作。操作系统并不会为我们做数据访问的串行化。如果允许其中的某个线程在没有得到锁的情况下也可以访问共享线程同步资源,那么即使其他的线程在使用共享资源前都申请锁,也还是会出现数据不一致的问题。
上述操作有一个前提,那就是锁本身必须具有原子性。锁保护的是不具有原子性的公共资源,任何执行流想访问公共资源必须先加锁,问题就转化为了并发的线程访问锁这个公共资源。只要锁具有原子性,那么锁保护的资源就可以安全的被访问。
多线程在一段时间片内会推进各自的任务,给多线程加锁的本质是让并行的线程变成串行,可以理解为让一个不是原子性的操作有原子性的特性。
串行之后耗费的时间肯定比并行多,这种行为本质是拿时间换安全~
互斥变量的数据类型
在POSIX线程(pthread)库中,互斥变量使用pthread_mutex_t
数据类型表示。这是用于创建和管理互斥锁的基本类型。
静态分配的互斥量
对于静态分配的互斥量(即在编译时就已经分配好的互斥量),可以直接使用常量PTHREAD_MUTEX_INITIALIZER
来初始化。这种方式简单方便,但仅适用于静态分配的互斥量。
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
动态分配的互斥量
对于动态分配的互斥量(例如,通过malloc
函数分配的内存),需要使用pthread_mutex_init
函数进行初始化。这个函数接受一个指向pthread_mutex_t
的指针和一个指向互斥量属性的指针(通常为NULL以使用默认属性)。
#include<pthread.h>
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t*restrict attr);
pthread_mutex_init
用于初始化一个未初始化的互斥量。- 第一个参数是指向要初始化的互斥量的指针。
- 第二个参数是一个指向互斥量属性的指针,可以设置为NULL以使用默认属性。
- 成功时返回0,失败时返回错误编号
pthread_mutex_t *mutex_ptr = malloc(sizeof(pthread_mutex_t));
if (mutex_ptr != NULL) {
pthread_mutex_init(mutex_ptr, NULL);
}
互斥变量的销毁
在动态分配的互斥量不再需要时,应该使用pthread_mutex_destroy
函数来销毁它。这个函数确保互斥量被正确清理,避免资源泄露。注意,销毁后的互斥量不应再次使用,除非重新进行初始化。销毁后,如果之前是通过malloc
等函数分配的内存,还需要释放该内存。
int pthread_mutex_destroy(pthread_mutex_t *mutex);
pthread_mutex_destroy
用于销毁一个已初始化的互斥量。- 参数是指向要销毁的互斥量的指针。
- 成功时返回0,失败时返回错误编号
pthread_mutex_destroy(mutex_ptr);
free(mutex_ptr);
pthread_mutex_init和pthread_mutex_destroy函数的返回值用于指示操作是否成功。如果成功,返回0;如果失败,返回错误编号。
当使用默认的互斥量属性时,将attr参数设置为NULL。
需要在适当的时机对互斥量进行加锁(pthread_mutex_lock)和解锁(pthread_mutex_unlock)操作,以控制对共享资源的访问。
使用PTHREAD_ MUTEX_ INITIALIZER初始化的互斥量不需要销毁
互斥量的加锁和解锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
此函数用于锁定指定的互斥锁(mutex
)。如果互斥锁已经被另一个线程锁定,调用线程将被阻塞,直到互斥锁变为可用(即,它被解锁)。如果互斥锁成功被锁定,函数返回 0
;如果发生错误,则返回错误码。
int pthread_mutex_unlock(pthread_mutex_t *mutex);
此函数用于解锁指定的互斥锁(mutex
)。如果互斥锁当前被调用线程锁定,它将变为未锁定状态,并允许其他线程锁定它。如果互斥锁成功被解锁,函数返回 0
;如果发生错误(例如,如果调用线程没有锁定该互斥锁),则返回错误码。
抢票代码的改进
静态初始化示例
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <vector>
#include <string>
int tickets = 100; // 门票数量
#define N_THREAD 4 // 线程数量
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; // 初始化互斥锁
void *ExecuteFlow(void *arg)
{
while (true)
{
pthread_mutex_lock(&lock); // 锁定互斥锁
if (tickets > 0)
{
usleep(10000); // 模拟抢票前的准备时间
tickets--;
printf("%p抢到了,还剩%d门票\n", pthread_self(), tickets);
}
pthread_mutex_unlock(&lock); // 解锁互斥锁
if (tickets <= 0)
{
pthread_exit(nullptr); // 如果没有门票了,则退出线程
}
usleep(13); // 模拟抢票的后续动作消耗的时间
}
}
int main()
{
pthread_t thread[N_THREAD];
for (int i = 0; i < N_THREAD; i++)
{
pthread_create(&thread[i], nullptr, ExecuteFlow, nullptr); // 线程创建
}
for (int i = 0; i < N_THREAD; i++) // 主线程等待其他线程退出
{
pthread_join(thread[i], nullptr);
}
return 0;
}
动态初始化示例
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
int tickets = 100; // 门票数量
#define N_THREAD 4 // 线程数量
pthread_mutex_t lock; // 声明互斥锁,但不初始化
void *ExecuteFlow(void *arg)
{
while (true)
{
pthread_mutex_lock(&lock); // 锁定互斥锁
if (tickets > 0)
{
usleep(10000); // 模拟抢票前的准备时间
tickets--;
printf("%p抢到了,还剩%d门票\n", pthread_self(), tickets);
}
pthread_mutex_unlock(&lock); // 解锁互斥锁
if (tickets <= 0)
{
pthread_exit(nullptr); // 如果没有门票了,则退出线程
}
usleep(13); // 模拟抢票的后续动作消耗的时间
}
}
int main()
{
pthread_t thread[N_THREAD];
// 动态初始化互斥锁
if (pthread_mutex_init(&lock, NULL) != 0) {
printf("互斥锁初始化失败\n");
return 1;
}
for (int i = 0; i < N_THREAD; i++)
{
pthread_create(&thread[i], NULL, ExecuteFlow, NULL); // 线程创建
}
for (int i = 0; i < N_THREAD; i++) // 主线程等待其他线程退出
{
pthread_join(thread[i], NULL);
}
// 销毁互斥锁
pthread_mutex_destroy(&lock);
return 0;
}
结果如下
加锁之后并没有出现负数还在抢票的问题。
临界区
在pthread_mutex_lock和pthread_mutex_unlock之间的代码称为临界区,临界区的代码就是线程要串行的代码,也就是说临界区的代码越多,代码执行的效率越低。所以,我们用加锁和解锁的代码来框定临界区的时候,代码越少越好。
那么把 if (tickets > 0) 放到临界区外面怎么样呢?这么做也是不安全的,判断票数够不够本身是在访问公共资源的一种状态。状态也是公共资源的一部分,也是需要被锁保护起来的~
锁的基本原理
在并发编程中,原子操作是一个非常重要的概念。原子操作指的是一个操作在执行过程中不会被线程调度机制中断的操作,这种操作一旦开始,就会一直运行到结束,中间不会有任何线程切换。i++
或++i
操作,在单线程环境下看似简单,但在多线程环境中,由于这些操作通常涉及读取、修改和写回内存的步骤,因此它们并不是原子的。当多个线程尝试同时修改同一个变量时,就可能发生数据一致性问题,即最终的结果可能不是任何一个线程预期的结果。
为了解决在多处理器平台上进行原子操作的问题,许多计算机系统提供了swap
或exchange
指令。这些指令的作用是将寄存器中的值与内存中的值进行交换,并且这一交换操作是原子的,即在整个过程中不会被其他处理器的操作打断。由于只有一条指令完成了整个交换过程,因此它保证了操作的原子性。即使在多处理器平台上,访问内存的总线周期也有先后,当一个处理器上的交换指令正在执行时,另一个处理器上的相同操作只能等待,直到总线周期结束。
即使时多核CPU,内存和CPU的交互也不会发生数据错乱问题,在CPU中有仲裁器来调度那个核心可以交互内存。仲裁器是管理多核或多处理器系统中多个设备对共享资源(如总线、内存等)竞争访问的关键组件。它通过实现如轮询、优先级或动态仲裁等算法,确保资源的高效和公平分配,从而提升系统性能、稳定性和吞吐量。仲裁器在防止资源冲突、避免死锁以及确保系统稳定运行方面发挥着重要作用。
下面是线程抢到锁和释放锁伪代码示例
al表示CPU上的寄存器,mutex表示内存,线程需要先把寄存器上的值清零,然后拿寄存器上的值和内存上的值做交换(此时内存上的值为吧 1)。在上述两个步骤中,只能有一个线程的上下文有1的标识,即抢到了锁,即使该线程被切走,其他线程也是拿0和内存的0做交换,也就是说1只有一个。只有线程上下文中有1标识的,再次被CPU调度运行时不会被挂起(if判断)。
解锁时也很有意思,时往内存里写入1,而不是让线程拿自己上下文标识的数字交换,换句话说,每个线程不管有没有抢到锁,都可以再“造”一把锁。这样的设计并不是BUG,而是为了避免死锁问题。
各种锁
了解了锁的基本原理后,下面各种锁的概念是基于具体场景的细分。
悲观锁
乐观锁
自旋锁
自旋锁与其他类型的锁(如互斥锁)的主要区别在于,当锁不可用(即已被其他线程持有)时,尝试获取锁的线程不会进入休眠状态,而是会持续循环并检查锁是否可用。这种“自旋”的行为意味着线程会持续消耗CPU资源,直到它能够获取到锁为止。
自旋锁在某些场景下非常有用,特别是在锁被持有时间非常短的情况下。由于避免了线程休眠和唤醒的开销,自旋锁可以减少延迟并提高性能。然而,如果锁被长时间持有,使用自旋锁可能会导致大量的CPU资源浪费,因为线程会持续占用CPU进行无意义的循环检查。
公平锁
公平锁是指多个线程按照申请锁的顺序来获取锁,即类似于排队机制,先来先服务。当一个线程释放锁后,等待时间最长的线程会获得锁的访问权。线程按照请求锁的顺序排队等待,确保每个线程都有机会公平地获取到锁。通过排队机制,可以有效避免某些线程长时间无法获取锁而导致的饥饿现象。由于需要维护一个队列来管理等待的线程,因此公平锁的开销相对较大。在需要确保线程公平访问共享资源的场景中,如银行排队系统、资源分配系统等,公平锁是一个很好的选择。
非公平锁
非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。线程获取锁的顺序是不确定的,不保证先来先服务的原则。由于允许线程插队获取锁,非公平锁在某些情况下可以提高系统的吞吐量。在某些极端情况下,非公平锁可能会导致某些线程长时间无法获取锁,产生饥饿现象。在追求高吞吐量、对公平性要求不高的场景中,如网络服务器处理请求、数据库事务处理等,非公平锁可能是一个更好的选择。
自旋锁接口
初始化
#include <pthread.h>
int pthread_spin_init(pthread_spinlock_t *lock, int pshared);
lock:指向自旋锁变量的指针。
pshared:控制锁的进程共享属性。如果设置为PTHREAD_PROCESS_SHARED,则锁可以被不同进程中的线程共享;如果设置为PTHREAD_PROCESS_PRIVATE(默认值),则锁只能被初始化它的进程内的线程访问。
返回值:成功时返回0,失败时返回错误码。
销毁
int pthread_spin_destroy(pthread_spinlock_t *lock);
lock:指向要销毁的自旋锁变量的指针。
返回值:成功时返回0,失败时返回错误码。
阻塞式加锁
int pthread_spin_lock(pthread_spinlock_t *lock);
该函数会一直自旋等待,直到锁被释放并成功获取。如果线程已经持有该锁,则行为未定义,可能导致死锁或永久自旋。
非阻塞式加锁
int pthread_spin_trylock(pthread_spinlock_t *lock);
尝试获取锁,如果锁已被其他线程持有,则立即返回EBUSY
错误,不会使线程进入自旋状态。
解锁
int pthread_spin_unlock(pthread_spinlock_t *lock);
释放自旋锁,使其他等待的线程可以尝试获取锁。
读写锁
概述
读写锁(reader-wniterlock)与互斥量类似,不过读写锁允许更高的并行性。互斥量要么是锁住状态,要么就是不加锁状态,而且一次只有一个线程可以对其加锁。读写锁可以有3种状态:,读模式下加锁状态,写模式下加锁状态,不加锁状态。一次只有一个线程可以占有写模式的读写,锁,但是多个线程可以同时占有读模式的读写锁。,当读写锁是写加锁状态时,在这个锁被解锁之前,所有试图对这个锁加锁的线程都会被阻塞。,当读写锁在读加锁状态时,所有试图以读模式对它进行加锁的线程都可以得到访问权,但是任何希望以写模式对此锁进行加锁的线程都会阻塞,直到所有的线程释放它们的读锁为止。虽然各操作系统对读写锁的实现各不相同,但当读写锁处于读模式锁住的状态,而这时有一个线程试图以写模式获取锁时,读写锁通常会阻塞随后的读模式锁请求。这样可以避免读模式锁长期占用而等待的写模式锁请求一直得不到满足。读写锁非常适合于对数据结构读的次数远大于写的情况。当读写锁在写模式下时,它所保护的数据结构就可以被安全地修改,因为一次只有一个线程可以在写模式下拥有这个锁。当读写锁在读模式下时只要线程先获取了读模式下的读写锁,该锁所保护的数据结构就可以被多个获得读模式锁的线程读取。读写锁也叫做共享互斥锁(shared-exclusivelock)。当读写锁是读模式锁住时,就可以说成是,以共享模式锁住的。当它是写模式锁住的时候就可以说成是以互斥模式锁住的。与互斥量相比,读写锁在使用之前必须初始化,在释放它们底层的内存之前必须销毁。
初始化
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,
const pthread_rwlockattr_t *restrict attr);
pthread_rwlock_init 用于初始化一个未初始化的读写锁对象 rwlock。如果 attr 不为 NULL,则使用 attr 指定的属性来初始化读写锁;如果 attr 为 NULL,则使用默认属性初始化读写锁。
成功时返回 0;出错时返回错误码。
销毁
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
pthread_rwlock_destroy 用于销毁由 rwlock 指定的读写锁对象,并释放其占用的资源。在调用此函数后,rwlock 不再是一个有效的读写锁。
成功时返回 0;出错时返回错误码。
加锁
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
pthread_rwlock_rdlock 用于在读模式下锁定 rwlock 指定的读写锁。如果读写锁当前没有被任何线程锁定(无论是读模式还是写模式),则调用线程获得读写锁并进入读模式。如果读写锁已经被锁定在写模式下,则调用线程将被阻塞,直到锁被释放。如果读写锁已经被其他线程以读模式锁定,则调用线程可能立即获得锁(取决于具体的实现和系统的状态)。
成功时返回 0;出错时返回错误码。
解锁
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
pthread_rwlock_unlock
用于解锁 rwlock
指定的读写锁。无论是读模式还是写模式下获得的锁,都可以通过此函数来释放。
成功时返回 0;出错时返回错误码。
非阻塞加锁
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);
这两个函数分别尝试以读模式和写模式锁定 rwlock
指定的读写锁。与它们的阻塞版本不同,如果锁当前不可用(即已被其他线程锁定),则这些函数不会阻塞调用线程,而是立即返回 EBUSY 错误。
成功时返回 0;如果锁不可用,则返回 EBUSY;出错时返回其他错误码。
死锁问题
概念
经典示意图
![](https://i-blog.csdnimg.cn/direct/0b85695b5acb4debb156825628594d2f.png)
死锁的代码示例
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
// 定义两个互斥锁
pthread_mutex_t lock1 = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t lock2 = PTHREAD_MUTEX_INITIALIZER;
// 线程函数1
void* thread1(void* arg) {
pthread_mutex_lock(&lock1);
printf("Thread 1: locked lock1\n");
// 故意让出CPU时间,以便线程2有机会运行
sleep(1);
// 尝试锁定lock2,但此时它可能被线程2持有
pthread_mutex_lock(&lock2);
printf("Thread 1: locked lock2\n");
// 解锁
pthread_mutex_unlock(&lock2);
pthread_mutex_unlock(&lock1);
pthread_exit(NULL);
}
// 线程函数2
void* thread2(void* arg) {
pthread_mutex_lock(&lock2);
printf("Thread 2: locked lock2\n");
// 故意让出CPU时间,以便线程1有机会运行
sleep(1);
// 尝试锁定lock1,但此时它可能被线程1持有
pthread_mutex_lock(&lock1);
printf("Thread 2: locked lock1\n");
// 解锁
pthread_mutex_unlock(&lock1);
pthread_mutex_unlock(&lock2);
pthread_exit(NULL);
}
int main() {
pthread_t t1, t2;
// 创建线程
pthread_create(&t1, NULL, thread1, NULL);
pthread_create(&t2, NULL, thread2, NULL);
// 等待线程结束
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_mutex_destroy(&lock1);
pthread_mutex_destroy(&lock2);
return 0;
}
thread1
首先锁定 lock1
,然后尝试锁定 lock2
。而 thread2
首先锁定 lock2
,然后尝试锁定 lock1
。如果两个线程几乎同时运行,它们将分别持有一个锁并等待对方释放另一个锁,从而导致死锁。
死锁的必要条件
互斥:
一次只有一个进程可以使用一个资源。其他进程不能访问已分配给其他进程的资源。
目的是确保数据的一致性和完整性,如数据库操作中的互斥访问。
占有且等待:
当一个进程等待其他进程释放资源时,它继续占有已分配给自己的资源。
这意味着进程在等待其他资源的同时,不会释放已持有的资源。
不可抢占:
不能强行从进程那里抢占已占有的资源。
这意味着一旦资源被分配,只有持有者才能释放它,外部不能干预。
循环等待:
这是一个不可解的循环等待状态,因为它导致了死锁。
存在一个封闭的进程链,使得每个进程至少占有链中下一个进程所需的一个资
这四个条件共同构成了死锁的充分必要条件。只有当这四个条件同时满足时,才会发生死锁。其中,前三个条件是死锁的必要条件,但不足以说明一定会发生死锁;第四个条件(循环等待)则是这些必要条件可能导致死锁的潜在结果。
在设计和实现并发系统时,需要特别注意这些条件,以避免死锁的发生。一种常见的策略是确保至少不满足这四个条件中的一个,从而破坏死锁的发生条件。
条件变量
初始化
pthread_cond_init
函数用于初始化一个条件变量。这个函数是 POSIX 线程(pthread)库中的一个重要部分,用于线程同步。条件变量通常与互斥锁(mutex)一起使用,以允许线程等待某个条件变为真。
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
cond:指向要初始化的条件变量的指针。这是一个 pthread_cond_t 类型的变量,它代表了条件变量本身。
attr:指向条件变量属性的指针。这个参数通常设置为 NULL,表示使用默认的条件变量属性。如果指定了非 NULL 的 attr 参数,则应该首先通过调用 pthread_condattr_init 函数来初始化该属性对象,并可以通过 pthread_condattr_set* 函数族来设置特定的属性。然而,在大多数应用场景中,默认属性已经足够,因此不需要特别设置这个参数。
成功时,pthread_cond_init 返回 0。
失败时,返回一个错误码,指示错误的原因。
销毁
pthread_cond_destroy
函数用于销毁一个条件变量。这个函数是 POSIX 线程(pthread)库中的一部分,用于释放与条件变量相关联的资源。一旦条件变量被销毁,它就不能再被使用,除非它再次被 pthread_cond_init
初始化。
int pthread_cond_destroy(pthread_cond_t *cond);
cond:指向要销毁的条件变量的指针。这个条件变量必须之前已经被成功初始化。
成功时,pthread_cond_destroy 返回 0。
失败时,返回一个错误码,指示错误的原因。然而,在正常情况下,销毁一个条件变量很少会失败,除非在调用 pthread_cond_destroy 时,条件变量是未初始化的,或者它正在被某个线程等待(即,有一个或多个线程在 pthread_cond_wait、pthread_cond_timedwait 或 pthread_cond_signal/pthread_cond_broadcast 的调用中阻塞)。
等待
pthread_cond_wait
函数是 POSIX 线程(pthread)库中的一个重要同步原语,用于阻塞当前线程,直到指定的条件变量被另一个线程唤醒。这个函数必须与互斥锁一起使用,以确保条件检查(wait前的条件)和条件变量的修改(signal或broadcast时的条件变化)之间的原子性。
int pthread_cond_wait(pthread_cond_t * cond, pthread_mutex_t * mutex);
cond:指向要等待的条件变量的指针。
mutex:指向当前线程已经锁定的互斥锁的指针。在调用 pthread_cond_wait 之前,调用线程必须已经锁定了这个互斥锁。在 pthread_cond_wait 内部,这个函数会自动解锁互斥锁,允许其他线程修改条件,然后调用 pthread_cond_signal 或 pthread_cond_broadcast 来唤醒一个或所有等待的线程。当 pthread_cond_wait 返回时,互斥锁会再次被锁定,以确保条件检查(通常在 pthread_cond_wait 调用之后的循环中)的原子性。
成功时,pthread_cond_wait 返回 0。
失败时,返回一个错误码,但这种情况很少见,因为 pthread_cond_wait 的主要失败原因(如条件变量或互斥锁未初始化)通常在调用之前就应该被检查和处理。
pthread_cond_wait
应该总是在一个循环中调用,因为当线程被唤醒时,它需要重新检查条件是否真正满足(即,所谓的“虚假唤醒”是可能的,尽管在实际实现中很少见)。
唤醒
pthread_cond_broadcast
和
pthread_cond_signal
函数是 POSIX 线程(pthread)库中用于唤醒等待条件变量的线程的函数。这两个函数都与条件变量一起使用,以在特定条件发生时通知一个或多个线程。
int pthread_cond_broadcast(pthread_cond_t *cond);
pthread_cond_broadcast
函数用于唤醒等待指定条件变量的所有线程。
参数:cond 是指向要广播的条件变量的指针。
返回值:成功时返回 0;失败时返回错误码。
int pthread_cond_signal(pthread_cond_t *cond);
pthread_cond_signal
函数用于唤醒等待指定条件变量的一个线程(如果有的话)。如果有多个线程在等待该条件变量,则选择哪一个线程被唤醒是未定义的,一般默认是第一个。
参数:与 pthread_cond_broadcast
相同,cond
是指向要发出信号的条件变量的指针。
返回值:同样,成功时返回 0
;失败时返回错误码。
互斥锁:在调用 pthread_cond_broadcast 或 pthread_cond_signal 之前,必须锁定与条件变量相关联的互斥锁。这是因为在唤醒等待的线程后,这些线程会尝试重新锁定该互斥锁以继续执行。如果不这样做,可能会导致死锁或条件竞争。
虚假唤醒:尽管 pthread_cond_wait 会在条件变量的条件不满足时阻塞线程,但线程可能会因为某些实现特定的原因(如系统调用中断)而被“虚假唤醒”。因此,在 pthread_cond_wait 返回后,通常需要将等待操作放在一个循环中,并重新检查条件是否真正满足。关于虚假唤醒,后面会配上代码(具体的场景)讲讲一遍。
唤醒策略:选择 pthread_cond_broadcast 还是 pthread_cond_signal 取决于具体的应用场景。如果你知道只有一个线程会响应条件变化,那么使用 pthread_cond_signal 可能更高效。但如果你不确定有多少线程会等待条件变量,或者所有等待的线程都需要被唤醒,那么使用 pthread_cond_broadcast 更为合适。
信号量
信号量的本质是一把计数器,它的操作也是原子的
初始化
int sem_init(sem_t *sem, int pshared, unsigned int value);
sem: 指向 sem_t 类型的指针,这个指针指向的信号量将被初始化。sem_t 是 POSIX 标准中定义的一个数据类型,用于表示信号量。
pshared: 这个参数决定了信号量是否可以在多个进程间共享。如果 pshared 的值为 0,则信号量只能用于线程间的同步(即,信号量只能在同一进程的线程之间共享)。如果 pshared 的值非 0(通常是 1),则信号量可以在多个进程间共享,前提是这些进程能够访问到同一个 sem_t 对象的内存位置。
value: 信号量的初始值。这个值应该是非负的。信号量的值表示了可用资源的数量或者可以进行的操作的次数。
成功时,sem_init 返回 0。
出错时,返回 -1,并设置 errno 以指示错误。
#include <semaphore.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
sem_t sem;
void *thread_func(void *arg) {
sem_wait(&sem); // 等待信号量
// 执行受保护的代码
printf("Entered critical section\n");
// 离开临界区
sem_post(&sem); // 释放信号量
return NULL;
}
int main() {
pthread_t t1, t2;
// 初始化信号量,初始值为1,仅在线程间共享
if (sem_init(&sem, 0, 1) == -1) {
perror("sem_init");
exit(EXIT_FAILURE);
}
// 创建线程
pthread_create(&t1, NULL, thread_func, NULL);
pthread_create(&t2, NULL, thread_func, NULL);
// 等待线程结束
pthread_join(t1, NULL);
pthread_join(t2, NULL);
// 销毁信号量
sem_destroy(&sem);
return 0;
}
销毁
int sem_destroy(sem_t *sem);
sem: 指向 sem_t 类型的指针,这个指针指向的信号量将被销毁。
成功时,sem_destroy 返回 0。
出错时,返回 -1,并设置 errno 以指示错误。
等待
int sem_wait(sem_t *sem);
sem: 指向 sem_t 类型的指针,这个指针指向的信号量将被等待(即,其值将被检查并可能减少)。
成功时,sem_wait 返回 0。
出错时,返回 -1,并设置 errno 以指示错误。
#include <semaphore.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
sem_t sem;
void *thread_func(void *arg) {
sem_wait(&sem); // 等待信号量,如果信号量的值为0,则线程将阻塞
// 执行受保护的代码
printf("Entered critical section\n");
// 离开临界区
sem_post(&sem); // 释放信号量
return NULL;
}
int main() {
pthread_t t1, t2;
// 初始化信号量
if (sem_init(&sem, 0, 1) == -1) {
perror("sem_init");
exit(EXIT_FAILURE);
}
// 创建线程
pthread_create(&t1, NULL, thread_func, NULL);
pthread_create(&t2, NULL, thread_func, NULL);
// 等待线程结束
pthread_join(t1, NULL);
pthread_join(t2, NULL);
// 销毁信号量
sem_destroy(&sem);
return 0;
}
发布
int sem_post(sem_t *sem);
sem: 指向 sem_t 类型的指针,这个指针指向的信号量将被增加其值。
成功时,sem_post 返回 0。
出错时,返回 -1,并设置 errno 以指示错误。
#include <semaphore.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
sem_t sem;
void *thread_func(void *arg) {
sem_wait(&sem); // 等待信号量
// 执行受保护的代码
printf("Entered critical section\n");
// 离开临界区
sem_post(&sem); // 释放信号量,允许其他线程进入临界区
return NULL;
}
int main() {
pthread_t t1, t2;
// 初始化信号量
if (sem_init(&sem, 0, 1) == -1) {
perror("sem_init");
exit(EXIT_FAILURE);
}
// 创建线程
pthread_create(&t1, NULL, thread_func, NULL);
pthread_create(&t2, NULL, thread_func, NULL);
// 等待线程结束
pthread_join(t1, NULL);
pthread_join(t2, NULL);
// 销毁信号量
if (sem_destroy(&sem) == -1) {
perror("sem_destroy");
exit(EXIT_FAILURE);
}
return 0;
}
生产者消费者模型
有一个或多个生产者生产某种类型的数据(记录、字符),并放置在缓冲区中;有一个消费者从缓冲区中取数据,每次取一项;系统保证避免对缓冲区的重复操作,也就是说,在任何时候只有一个主体(生产者或消费者)可以访问缓冲区。问题是要确保这种情况,当缓冲区已满时,生产者不会继续向其中添加数据;当缓冲区为空时,消费者不会从中移走数据。
![](https://i-blog.csdnimg.cn/direct/f3cf20b6af354943b354f62974fb92b7.png)
基于阻塞队列的生产者消费者模型
容器代码
容器设计成了泛型,可以传对象进去,其中对象可以带有复杂的任务。
#include <pthread.h>
#include <queue>
template <class T>
class B_queue
{
public:
B_queue(int default_max = 10) // 构造
: _max(default_max), _min(0), _max_water((default_max * 2) / 3), _min_water((default_max * 1) / 3)
{
pthread_mutex_init(&_lock, nullptr);
pthread_cond_init(&_c_block, nullptr);
pthread_cond_init(&_p_bock, nullptr);
}
~B_queue() // 析构
{
pthread_mutex_destroy(&_lock);
pthread_cond_destroy(&_c_block);
pthread_cond_destroy(&_p_bock);
}
void push(const T &data) // 生产者接口
{
pthread_mutex_lock(&_lock); // 加锁
while (_q.size() == _max) // while可以防止伪唤醒带来的后果
{
pthread_cond_wait(&_p_bock, &_lock); // 挂起
}
_q.push(data);
if (_q.size() >= _max_water)
{
pthread_cond_signal(&_c_block); // 唤醒消费者
}
pthread_mutex_unlock(&_lock); // 解锁
}
void pop(T &data) // 消费者接口
{
pthread_mutex_lock(&_lock); // 加锁
while (_q.size() == _min) // while可以防止伪唤醒带来的后果
{
pthread_cond_wait(&_c_block, &_lock); // 挂起
}
data = _q.front();
_q.pop();
if (_q.size() <= _min_water)
{
pthread_cond_signal(&_p_bock); // 唤醒生产者
}
pthread_mutex_unlock(&_lock); // 解锁
}
private:
pthread_mutex_t _lock; // 互斥量
pthread_cond_t _c_block; // 消费者条件变量
pthread_cond_t _p_bock; // 生产者条件变量
std::queue<T> _q; // 队列容器
int _max; // 容器的最大值
int _min; // 容器的最小值
int _max_water; // 最高水位线
int _min_water; // 最低水位线
};
测试代码
#include "Blocking_queues.cc"
#include <stdio.h>
#include <utime.h>
#include <time.h>
#include <unistd.h>
#include <stdlib.h>
void *c_flow(void *arg) // 消费者线程
{
B_queue<int> *q = (B_queue<int> *)arg;
while (true)
{
int tmp;
q->pop(tmp);
printf("我是消费者,我拿到了%d\n", tmp);
// sleep(1);
}
}
void *p_flow(void *arg) // 生产者线程
{
B_queue<int> *q = (B_queue<int> *)arg;
while (true)
{
int random_number = rand() % 100; // 生成0到99之间的随机数
q->push(random_number);
printf("我是生产者,我生产了%d\n", random_number);
// sleep(1);
}
}
int main()
{
srand((unsigned int)time(NULL));
B_queue<int> q;
pthread_t c_thread[5];
pthread_t p_thread[3];
for (int i = 0; i < 5; i++)
{
pthread_create(&c_thread[i], nullptr, c_flow, &q);
pthread_detach(c_thread[i]);
}
for (int i = 0; i < 3; i++)
{
pthread_create(&p_thread[i], nullptr, p_flow, &q);
pthread_detach(p_thread[i]);
}
while (true)
{
sleep(1);
}
return 0;
}
伪唤醒
在判断资源状态时是用while循环,如果用if判断呢?
会给我们提示这样的错误,表示非法访问内存了,大概率是多线程并发访问容器时崩掉了。明明有了互斥量和条件变量保证了资源的原子性,为什么多线程并发还会有问题呢?
伪唤醒指的是在没有收到任何明确唤醒信号的情况下,等待条件变量的线程被意外唤醒的现象。这种唤醒并不是由于其他线程调用了notify_one
或notify_all
等通知函数导致的(或者是因为调用了这类函数代码本身有问题),而是由于系统调度或底层实现机制等因素引起的。
如果是if()判断, pthread_cond_wait函数在唤醒线程后,会让线程尝试锁住互斥量,没有成功则阻塞等待互斥量解锁,成功就往下执行。注意此时线程只会往下执行,并不会判断资源的情况,如果资源不满足但线程仍然访问了,就有可能会出问题。
std::unique_lock<std::mutex> lck(mtx);
while (!condition) { // 使用while循环来检查条件
cv.wait(lck); // 等待条件变量
}
// 执行后续操作
条件变量的伪唤醒是一个需要注意的问题,尤其是在编写多线程程序时。虽然无法完全避免伪唤醒的发生,但可以通过合理的编程技巧和策略来减少其对程序逻辑的影响。在使用条件变量时,建议始终在循环中等待条件满足,并在被唤醒后重新检查条件是否仍然满足。
基于环形队列的生产者消费者模型
容器代码
#include <vector>
#include <semaphore.h>
#include <pthread.h>
template <class T>
class annular
{
public:
annular(int max_c = 10)
: max_capacity(max_c), min_capacity(0), _c_subscript(0), _p_subscript(0)
{
_v.reserve(max_c);
pthread_mutex_init(&_c_lock, nullptr);
pthread_mutex_init(&_p_lock, nullptr);
sem_init(&_c_sem, 0, 0);
sem_init(&_p_sem, 0, max_c);
}
~annular()
{
pthread_mutex_destroy(&_c_lock);
pthread_mutex_destroy(&_p_lock);
sem_destroy(&_c_sem);
sem_destroy(&_p_sem);
}
void push(const T &data)
{
sem_wait(&_p_sem); // 信号量的p操作
pthread_mutex_lock(&_p_lock); // 加锁
_v[_p_subscript] = data;
_p_subscript++;
_p_subscript %= max_capacity;
sem_post(&_c_sem); // 信号量的v操作
pthread_mutex_unlock(&_p_lock); // 解锁
}
void pop(T &data)
{
sem_wait(&_c_sem); // 信号量的p操作
pthread_mutex_lock(&_c_lock); // 加锁
data = _v[_c_subscript];
_c_subscript++;
_c_subscript %= max_capacity;
sem_post(&_p_sem); // 信号量的v操作
pthread_mutex_unlock(&_c_lock); // 解锁
}
private:
pthread_mutex_t _c_lock; // 消费者互斥量
pthread_mutex_t _p_lock; // 生产者互斥量
sem_t _c_sem; // 消费者信号量
sem_t _p_sem; // 生产者信号量
int _c_subscript; // 消费者下标
int _p_subscript; // 生产者下标
std::vector<T> _v;
int max_capacity; // 容器最大容量
int min_capacity; // 容器最小容量
};
测试代码
#include <stdio.h>
#include <utime.h>
#include <time.h>
#include <unistd.h>
#include <stdlib.h>
#include "annular_q.cc"
void *c_flow(void *arg) // 消费者线程
{
annular<int> *v = (annular<int> *)arg;
while (true)
{
int tmp;
v->pop(tmp);
printf("我是消费者,我拿到了%d\n", tmp);
// sleep(1);
}
}
void *p_flow(void *arg) // 生产者线程
{
annular<int> *v = (annular<int> *)arg;
while (true)
{
int random_number = rand() % 100; // 生成0到99之间的随机数
v->push(random_number);
printf("我是生产者,我生产了%d\n", random_number);
// sleep(1);
}
}
int main()
{
srand((unsigned int)time(NULL));
annular<int> v;
pthread_t c_thread[5];
pthread_t p_thread[3];
for (int i = 0; i < 5; i++)
{
pthread_create(&c_thread[i], nullptr, c_flow, &v);
pthread_detach(c_thread[i]); // 线程分离
}
for (int i = 0; i < 3; i++)
{
pthread_create(&p_thread[i], nullptr, p_flow, &v);
pthread_detach(p_thread[i]);
}
while (true)
{
sleep(1);
}
return 0;
}
STL和智能指针的线程安全问题
STL容器的线程安全性
STL中的容器(如vector、list、map等)设计的主要目标是最大化性能。为了达到这一目的,STL容器在设计时并没有内置线程安全机制。如果要在STL容器上添加锁来确保线程安全,这将会显著降低容器的性能。因为每次对容器的访问都需要进行锁的获取和释放,这增加了额外的开销。
对于不同的STL容器,如果尝试通过外部加锁来确保线程安全,由于不同容器的内部实现不同(例如,hash表可能采用锁表或锁桶的方式),这会导致性能上的差异。因此,当需要在多线程环境下使用STL容器时,通常需要开发者自行负责保证线程安全,例如通过外部加锁、使用互斥量(mutex)或其他同步机制。
智能指针的线程安全性
unique_ptr是一种独占的智能指针,它保证了在任何时刻只有一个unique_ptr可以拥有对某个对象的所有权。由于unique_ptr的作用域仅限于当前代码块,因此它不会涉及跨线程的所有权问题,从而自然保证了线程安全。
shared_ptr是一种共享的智能指针,允许多个shared_ptr实例共享对同一个对象的所有权。由于多个shared_ptr实例可能需要同时修改同一个引用计数(用于跟踪有多少个shared_ptr指向该对象),因此存在线程安全问题。然而,C++标准库中的shared_ptr实现已经考虑到了这一点,通过使用原子操作(如CAS,即比较并交换)来确保引用计数的修改是线程安全的,且高效。
综上所述,图片中的讨论主要强调了STL容器默认不是线程安全的,以及智能指针(特别是shared_ptr)在多线程环境下的线程安全保证机制。