学习任务:
1、 信号:信号的分类、进程对信号的处理、向进程发送信号、信号掩码
2、 进程:进程与程序的概念、进程的内存布局、进程的虚拟地址空间、fork创建子进程、wait监视子进程
3、 学习进程间通信(管道和FIFO、信号、消息队列、信号量、共享内存、socket)
4、 线程:pthread_create、pthread_exit、pthread_join、pthread_cancel、pthread_detach
5、 学习线程同步(mutex、条件变量、自旋锁、读写锁)
6、 多线程编程、线程池
7、 使用的工具:虚拟机、开发板
要搞明白:
信号的基本概念;
信号的分类、Linux提供的各种不同的信号及其作用;
发出信号以及响应信号,信号由“谁”发送、由“谁”处理以及如何处理;
进程在默认情况下对信号的响应方式;
使用进程信号掩码来阻塞信号、以及等待信号等相关概念;
如何暂停进程的执行,并等待信号的到达
1 信号
1.1 信号的分类
信号是事件发生时对进程的通知机制,也可以把它称为软件中断。信号与硬件中断的相似之处在于能够打断程序当前执行的正常流程,其实是在软件层次上对中断机制的一种模拟。大多数情况下,是无法预测信号达到的准确时间,所以,信号提供了一种处理异步事件的方法。
信号的目的是用来通信,当发生某种情况下,通过信号将情况“告知”相应的进程,从而达到同步、通信的目的
可以产生信号的多种不同的条件:硬件发生异常、终端下输入了能够产生信号的特殊字符、kill命令、调用kill()、发生了软件事件
ps: kill命令是一个用户级别的工具,方便在命令行中操作;而kill()系统调用是在程序内部使用的接口,用于在程序代码中实现发送信号给其他进程的功能。它们的基本功能相同,都是发送信号来控制进程的行为,但使用场景和方式有所不同。kill命令通常用于手动或在脚本中控制进程,kill()系统调用则更多地用于编写具有进程控制功能的程序。
信号通常是发送给对应的进程,当信号到达后,该进程需要做出相应的处理措施
信号到达后,该进程需要做出相应的处理措施,通常进程会视具体信号执行以下操作之一:
忽略信号:也就是说,当信号到达进程后,该进程并不会去理会它、直接忽略,就好像是没有出该信号,信号对该进程不会产生任何影响。事实上,大多数信号都可以使用这种方式进行处理,但有两种信号却决不能被忽略,它们是SIGKILL和SIGSTOP,这两种信号不能被忽略的原因是:它们向内核和超级用户提供了使进程终止或停止的可靠方法。另外,如果忽略某些由硬件异常产生的信号,则进程的运行行为是未定义的。
捕获信号:当信号到达进程后,执行预先绑定好的信号处理函数。为了做到这一点,要通知内核在某种信号发生时,执行用户自定义的处理函数,该处理函数中将会对该信号事件作出相应的处理,Linux 系统提供了signal()系统调用可用于注册信号的处理函数,将会在后面向大家介绍。
执行系统默认操作:进程不对该信号事件作出处理,而是交由系统进行处理,每一种信号都会有其对应的系统默认的处理方式,对大多数信号来说,系统默认的处理方式就是终止该进程
信号是异步的
信号是异步事件的经典实例,产生信号的事件对进程而言是随机出现的,进程无法预测该事件产生的准确时间,进程不能够通过简单地测试一个变量或使用系统调用来判断是否产生了一个信号,这就如同硬件中断事件,程序是无法得知中断事件产生的具体时间,只有当产生中断事件时,才会告知程序、然后打断当前程序的正常执行流程、跳转去执行中断服务函数,这就是异步处理方式。
信号本质上是int类型的数字编号,这就好比硬件中断所对应的中断号。内核针对每个信号,都给其定义了一个唯一的整数编号,从数字1开始顺序展开(没有0)。并且每一个信号都有其对应的名字(其实就是一个宏),信号名字与信号编号乃是一一对应关系,但是由于每个信号的实际编号随着系统的不同可能会不一样,所以在程序当中一般都使用信号的符号名
1.4 信号掩码(阻塞信号传递)
内核为每一个进程维护了一个信号掩码(其实就是一个信号集),即一组信号。当进程接收到一个属于信号掩码中定义的信号时,该信号将会被阻塞、无法传递给进程进行处理,那么内核会将其阻塞,直到该信号从信号掩码中移除,内核才会把该信号传递给进程从而得到处理。
详解:信号掩码是一个进程用于控制哪些信号可以被接收和处理的机制。在 Linux(以及其他类 Unix 系统)中,每个进程都有一个信号掩码,它决定了进程在当前时刻能够接收哪些信号。当一个信号被信号掩码阻塞时,即使该信号被发送到进程,进程也不会立即对其进行处理,而是将信号挂起,直到信号掩码允许该信号被接收
- 当应用程序调用 signal() 或 sigaction() 函数为某一个信号设置处理方式时,进程会自动将该信号添加到信号掩码中,这样保证了在处理一个给定的信号时,如果此信号再次发生,那么它将会被阻塞;当然对于sigaction() 而言,是否会如此,需要根据 sigaction()函数是否设置了 SA_NODEFER 标志而定;当信号处理函数结束返回后,会自动将该信号从信号掩码中移除
- 使用 sigaction() 函数为信号设置处理方式时,可以额外指定一组信号,当调用信号处理函数时将该组信号自动添加到信号掩码中,当信号处理函数结束返回后,再将这组信号从信号掩码中移除;通过sa_mask 参数进行设置
- 除了以上两种方式之外,还可以使用 sigprocmask() 系统调用,随时可以显式地向信号掩码中添加 / 移除信号
阻塞等待信号sigsuspend()
实时信号****sigpending()函数
如果进程当前正在执行信号处理函数,在处理信号期间接收到了新的信号,如果该信号是信号掩码中的成员,那么内核会将其阻塞,将该信号添加到进程的等待信号集(等待被处理,处于等待状态的信号)中,为了确定进程中处于等待状态的是哪些信号,可以使用sigpending()函数获取
2 进程
2.1 进程与程序的概念
进程其实就是一个可执行程序的实例,这句话如何理解呢?可执行程序就是一个可执行文件,文件是一个静态的概念,存放磁盘中,如果可执行文件没有被运行,那它将不会产生什么作用,当它被运行之后,它将会对系统环境产生一定的影响,所以可执行程序的实例就是可执行文件被运行。
进程是一个动态过程,而非静态文件,它是程序的一次运行过程,当应用程序被加载到内存中运行之后它就称为了一个进程,当程序运行结束后也就意味着进程终止,这就是进程的一个生命周期。
Linux 系统下的每一个进程都有一个进程号(process ID,简称PID),进程号是一个正数,用于唯一标识系统中的某一个进程
2.2 进程的内存布局
C语言程序一直都是由以下几部分组成的:
- 正文段:也可称为代码段,这是CPU执行的机器语言指令部分,文本段具有只读属性,以防止程序由于意外而修改其指令;正文段是可以共享的,即使在多个进程间也可同时运行同一段程序。
- 初始化数据段。通常将此段称为数据段,包含了显式初始化的全局变量和静态变量,当程序加载到内存中时,从可执行文件中读取这些变量的值。
- 未初始化数据段。包含了未进行显式初始化的全局变量和静态变量,通常将此段称为bss段,这一名词来源于早期汇编程序中的一个操作符,意思是“由符号开始的块”(block started by symbol),在程序开始执行之前,系统会将本段内所有内存初始化为0,可执行文件并没有为bss段变量分配存储空间,在可执行文件中只需记录bss段的位置及其所需大小,直到程序运行时,由加载器来分配这一段内存空间。
- 栈。函数内的局部变量以及每次函数调用时所需保存的信息都放在此段中,每次调用函数时,函数传递的实参以及函数返回值等也都存放在栈中。栈是一个动态增长和收缩的段,由栈帧组成,系统会为每个当前调用的函数分配一个栈帧,栈帧中存储了函数的局部变量(所谓自动变量)、实参和返回值。
- 堆。可在运行时动态进行内存分配的一块区域,譬如使用malloc()分配的内存空间,就是从系统堆内存中申请分配的。
的size 命令可以查看二进制可执行文件的文本段、数据段、bss段的段大小
Linux/x86-32 体系中进程内存布局
2.3 进程的虚拟地址空间
Linux系统中,采用了虚拟内存管理技术,事实上大多数现在操作系统都是如此
Linux系统中,每一个进程都在自己独立的地址空间中运行,在32位系统中,每个进程的逻辑地址空间均为4GB,这4GB的内存空间按照3:1的比例进行分配,其中用户进程享有3G的空间,而内核独自享有剩下的1G空间
虚拟地址会通过硬件MMU(内存管理单元)映射到实际的物理地址空间中,建立虚拟地址到物理地址的映射关系后,对虚拟地址的读写操作实际上就是对物理地址的读写操作,MMU会将物理地址“翻译”为对应的物理地址
以上是虚拟地址到物理地址的映射关系
为什么需要引入虚拟地址?
计算机物理内存的大小是固定的,就是计算机的实际物理内存,如果操作系统没有虚拟地址机制,所有的应用程序访问的内存地址就是实际的物理地址,所以要将所有应用程序加载到内存中,但是我们实际的物理内存只有4G,所以就会出现一些问题:
- 当多个程序需要运行时,必须保证这些程序用到的内存总量要小于计算机实际的物理内存的大小。
- 内存使用效率低。内存空间不足时,就需要将其它程序暂时拷贝到硬盘中,然后将新的程序装入内存。然而由于大量的数据装入装出,内存的使用效率就会非常低。
- 进程地址空间不隔离。由于程序是直接访问物理内存的,所以每一个进程都可以修改其它进程的内存数据,甚至修改内核地址空间中的数据,所以有些恶意程序可以随意修改别的进程,就会造成一些破坏,系统不安全、不稳定。
- 无法确定程序的链接地址。程序运行时,链接地址和运行地址必须一致,否则程序无法运行!因为程序代码加载到内存的地址是由系统随机分配的,是无法预知的,所以程序的运行地址在编译程序时是无法确认的。
引入了虚拟地址机制,程序访问存储器所使用的逻辑地址就是虚拟地址,通过逻辑地址映射到真正的物理内存上。所有应用程序运行在自己的虚拟地址空间中,使得进程的虚拟地址空间和物理地址空间隔离开来,这样做带来了很多的优点:
- 进程与进程、进程与内核相互隔离。一个进程不能读取或修改另一个进程或内核的内存数据,这是因为每一个进程的虚拟地址空间映射到了不同的物理地址空间。提高了系统的安全性与稳定性。
- 在某些应用场合下,两个或者更多进程能够共享内存。因为每个进程都有自己的映射表,可以让不同进程的虚拟地址空间映射到相同的物理地址空间中。通常,共享内存可用于实现进程间通信。
- 便于实现内存保护机制。譬如在多个进程共享内存时,允许每个进程对内存采取不同的保护措施,例如,一个进程可能以只读方式访问内存,而另一进程则能够以可读可写的方式访问。
- 编译应用程序时,无需关心链接地址。前面提到了,当程序运行时,要求链接地址与运行地址一致,在引入了虚拟地址机制后,便无需关心这个问题。
2.4 fork创建子进程
一个现有的进程可以调用fork()函数创建一个新的进程,调用fork()函数的进程称为父进程,由fork()函数创建出来的进程被称为子进程(child process)
在诸多的应用中,创建多个进程是任务分解时行之有效的方法,譬如,某一网络服务器进程可在监听客户端请求的同时,为处理每一个请求事件而创建一个新的子进程,与此同时,服务器进程会继续监听更多的客户端连接请求。在一个大型的应用程序任务中,创建子进程通常会简化应用程序的设计,同时提高了系统的并发性(即同时能够处理更多的任务或请求,多个进程在宏观上实现同时运行)
理解fork()系统调用的关键在于,完成对其调用后将存在两个进程,一个是原进程(父进程)、另一个则是创建出来的子进程,并且每个进程都会从fork()函数的返回处继续执行,会导致调用fork()返回两次值,子进程返回一个值、父进程返回一个值。在程序代码中,可通过返回值来区分是子进程还是父进程。
fork()调用成功后,将会在父进程中返回子进程的PID(一个大于 0 的值),而在子进程中返回值是 0;如果调用失败,父进程返回值-1,不创建子进程,并设置errno。
fork()调用成功后,子进程和父进程会继续执行fork()调用之后的指令,子进程、父进程各自在自己的进程空间中运行。事实上,子进程是父进程的一个副本,譬如子进程拷贝了父进程的数据段、堆、栈以及继承了父进程打开的文件描述符,父进程与子进程并不共享这些存储空间,这是子进程对父进程相应部分存储空间的完全复制,执行fork()之后,每个进程均可修改各自的栈数据以及堆段中的变量,而并不影响另一个进程。
虽然子进程是父进程的一个副本,但是对于程序代码段(文本段)来说,两个进程执行相同的代码段,因为代码段是只读的,也就是说父子进程共享代码段,在内存中只存在一份代码段数据。
系统调用 vfork()
Linux系统还提供了vfork()系统调用用于创建子进程,vfork()与fork()函数在功能上是相同的,并且返回值也相同,在一些细节上存在区别
可以将fork()认作对父进程的数据段、堆段、栈段以及其它一些数据结构创建拷贝,由此可以看出,使用fork()系统调用的代价是很大的,它复制了父进程中的数据段和堆栈段中的绝大部分内容,这将会消耗比较多的时间,效率会有所降低,而且太浪费,原因有很多,其中之一在于,fork()函数之后子进程通常会调用 exec 函数
ps:exec是一族函数,而不是一个单独的函数。这些函数的主要作用是在当前进程的上下文中加载并执行一个新的程序,替换当前进程的代码段、数据段和堆栈等内容。当exec函数成功执行后,新程序将从其main函数开始执行,并且原进程的代码、数据和栈空间等都会被新程序所替换,只有进程 ID(PID)等少数属性保持不变
vfork()与fork()一样都创建了子进程,但 vfork()函数并不会将父进程的地址空间完全复制到子进程中,因为子进程会立即调用exec(或_exit),于是也就不会引用该地址空间的数据。不过在子进程调用exec或_exit 之前,它在父进程的空间中运行、子进程共享父进程的内存。这种优化工作方式的实现提高的效率;但如果子进程修改了父进程的数据(除了vfork返回值的变量)、进行了函数调用、或者没有调用exec或_exit就返回将可能带来未知的结果。
另一个区别在于,vfork()保证子进程先运行,子进程调用exec之后父进程才可能被调度运行。
2.5 wait 监视子进程
对于许多需要创建子进程的进程来说,有时设计需要监视子进程的终止时间以及终止时的一些状态信息,在某些设计需求下这是很有必要的。系统调用wait()可以等待进程的任一子进程终止,同时获取子进程的终止状态信息
使用wait()系统调用存在着一些限制,这些限制包括如下:
- 如果父进程创建了多个子进程,使用wait()将无法等待某个特定的子进程的完成,只能按照顺序等待下一个子进程的终止,一个一个来、谁先终止就先处理谁;
- 如果子进程没有终止,正在运行,那么wait()总是保持阻塞,有时我们希望执行非阻塞等待,是否有子进程终止,通过判断即可得知;
- 使用wait()只能发现那些被终止的子进程,对于子进程因某个信号(譬如SIGSTOP信号)而停止(注意,这里停止指的暂停运行),或是已停止的子进程收到SIGCONT信号后恢复执行的情况就
无能为力了。
而**waitpid()**则可以突破这些限制
3 进程间通信
进程间通信(interprocess communication,简称 IPC)指两个进程之间的通信
系统中的每一个进程都有各自的地址空间,并且相互独立、隔离,每个进程都处于自己的地址空间中。所以同一个进程的不同模块(譬如不同的函数)之间进行通信都是很简单的
但是,两个不同的进程之间要进行通信通常是比较难的,因为这两个进程处于不同的地址空间中;通常情况下,大部分的程序是不要考虑进程间通信的,因为大家所接触绝大部分程序都是单进程程序(可以有多个线程),对于一些复杂、大型的应用程序,则会根据实际需要将其设计成多进程程序,譬如GUI、服务区应用程序等
以了解为主、了解进程间通信以及内核提供的进程间通信机制
工作当中参与开发的应用程序是一个多进程程序、需要考虑进程间通信的问题,此时再去深入学习
3.1 管道和FIFO
把一个进程连接到另一个进程的数据流称为管道,管道被抽象成一个文件
普通管道可用于具有亲缘关系的进程间通信,并且数据只能单向传输,如果要实现双向传输,则必须要使用两个管道;而流管道去除了普通管道的第一种限制,可以半双工的方式实现双向传输,但也只能在具有亲缘关系的进程间通信;而有名管道(FIFO)则同时突破了普通管道的两种限制,即可实现双向传输、又能在非亲缘关系的进程间通信
3.2 信号
用于通知接收信号的进程有某种事件发生,所以可用于进程间通信;除了用于进程间通信之外,进程还可以发送信号给进程本身
3.2 消息队列
消息队列是消息的链表,存放在内核中并由消息队列标识符标识,消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺陷。消息队列包括POSIX消息队列和System V消息队列。
消息队列是UNIX下不同进程之间实现共享资源的一种机制,UNIX允许不同进程将格式化的数据流以消息队列形式发送给任意进程,有足够权限的进程可以向队列中添加消息,被赋予读权限的进程则可以读走队列中的消息。
3.3 信号量
信号量是一个计数器,与其它进程间通信方式不大相同,它主要用于控制多个进程间或一个进程内的多个线程间对共享资源的访问,相当于内存中的标志,进程可以根据它判定是否能够访问某些共享资源,同时,进程也可以修改该标志,除了用于共享资源的访问控制外,还可用于进程同步。
它常作为一种锁机制,防止某进程在访问资源时其它进程也访问该资源,因此,主要作为进程间以及同一个进程内不同线程之间的同步手段。Linux提供了一组精心设计的信号量接口来对信号量进行操作,它们声明在头文件sys/sem.h中。
3.4 共享内存
共享内存就是映射一段能被其它进程所访问的内存,这段共享内存由一个进程创建,但其它的多个进程都可以访问,使得多个进程可以访问同一块内存空间。共享内存是最快的 IPC 方式,它是针对其它进程间通信方式运行效率低而专门设计的,它往往与其它通信机制,譬如结合信号量来使用,以实现进程间的同步和通信。
3.5 socket 套接字
Socket 是一种 IPC 方法,是基于网络的IPC 方法,允许位于同一主机(计算机)或使用网络连接起来的不同主机上的应用程序之间交换数据,说白了就是网络通信
Socket网络编程有课,后面实操
4 线程
应用编程中非常重要的编程技巧—线程(Thread);与进程类似,线程是允许应用程序并发执行多个任务的一种机制,线程参与系统调度,事实上,系统调度的最小单元是线程、而并非进程。虽然线程的概念比较简单,但是其所涉及到的内容比较多
线程是参与系统调度的最小单位。它被包含在进程之中,是进程中的实际运行单位。一个线程指的是进程中一个单一顺序的控制流(或者说是执行路线、执行流),一个进程中可以创建多个线程,多个线程实现并发运行,每个线程执行不同的任务。
当一个程序启动时,就有一个进程被操作系统创建,与此同时一个线程也立刻运行,该线程通常叫做程序的主线程(Main Thread),因为它是程序一开始时就运行的线程。应用程序都是以 main()做为入口开始运行的,所以 main()函数就是主线程的入口函数,main()函数所执行的任务就是主线程需要执行的任务。
所以由此可知,任何一个进程都包含一个主线程,只有主线程的进程称为单线程进程;
既然有单线程进程,那自然就存在多线程进程,所谓多线程指的是除了主线程以外,还包含其它的线程,其它线程通常由主线程来创建(调用pthread_create 创建一个新的线程),那么创建的新线程就是主线程的子线程
主线程的重要性体现在两方面:
- 其它新的线程(也就是子线程)是由主线程创建的;
- 主线程通常会在最后结束运行,执行各种清理工作,譬如回收各个子线程。
线程是程序最基本的运行单位,而进程不能运行,真正运行的是进程中的线程。当启动应用程序后,系统就创建了一个进程,可以认为进程仅仅是一个容器,它包含了线程运行所需的数据结构、环境变量等信息。
同一进程中的多个线程将共享该进程中的全部系统资源,如虚拟地址空间,文件描述符和信号处理等等。但同一进程中的多个线程有各自的调用栈(call stack,我们称为线程栈),自己的寄存器环境(register context)、自己的线程本地存储(thread-local storage)。
在多线程应用程序中,通常一个进程中包括了多个线程,每个线程都可以参与系统调度、被 CPU 执行,线程具有以下一些特点:
- 线程不单独存在、而是包含在进程中;
- 线程是参与系统调度的基本单位;
- 可并发执行。同一进程的多个线程之间可并发执行,在宏观上实现同时运行的效果;
- 共享进程资源。同一进程中的各个线程,可以共享该进程所拥有的资源,这首先表现在:所有线程都具有相同的地址空间(进程的地址空间),这意味着,线程可以访问该地址空间的每一个虚地址;
此外,还可以访问进程所拥有的已打开文件、定时器、信号量等等。
线程与进程?
进程创建多个子进程可以实现并发处理多任务(本质上便是多个单线程进程),多线程同样也可以实现(一个多线程进程)并发处理多任务的需求,那我们究竟选择哪种处理方式呢?
首先我们就需要来分析下多进程和多线程两种编程模型的优势和劣势。多进程编程的劣势:
进程间切换开销大。多个进程同时运行(指宏观上同时运行,无特别说明,均指宏观上),微观上依然是轮流切换运行,进程间切换开销远大于同一进程的多个线程间切换的开销,通常对于一些中小型应用程序来说不划算。
进程间通信较为麻烦。每个进程都在各自的地址空间中、相互独立、隔离,处在于不同的地址空间中,因此相互通信较为麻烦,在上一章节给大家有所介绍
解决方案便是使用多线程编程,多线程能够弥补上面的问题:
- 同一进程的多个线程间切换开销比较小。
- 同一进程的多个线程间通信容易。它们共享了进程的地址空间,所以它们都是在同一个地址空间中,通信容易。
- 线程创建的速度远大于进程创建的速度。
- 多线程在多核处理器上更有优势。
多线程也有它的缺点、劣势,譬如多线程编程难度高,对程序员的编程功底要求比较高,因为在多线程环境下需要考虑很多的问题,例如线程安全问题、信号处理的问题等,编写与调试一个多线程程序比单线程程序困难得多。
每个进程都有一个进程 ID ,每个线程也有其对应的标识,称为线程 ID
以下部分走马观花,如何实践
4.1 创建线程 pthread_create
启动程序时,创建的进程只是一个单线程的进程,称之为初始线程或主线程,如何创建一个新的线程
主线程可以使用库函数 pthread_create()负责创建一个新的线程,创建出来的新线程被称为主线程的子线程
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
- thread:pthread_t 类型指针,当 pthread_create()成功返回时,新创建的线程的线程 ID 会保存在参数 thread所指向的内存中,后续的线程相关函数会使用该标识来引用此线程。
- attr:pthread_attr_t 类型指针,指向 pthread_attr_t 类型的缓冲区,pthread_attr_t 数据类型定义了线程的各种属性。如果将参数 attr 设置为 NULL,那么表示将线程的所有属性设置为默认值,以此创建新线程。
- start_routine:参数 start_routine 是一个函数指针,指向一个函数,新创建的线程从start_routine()函数开始运行,该函数返回值类型为void * ,并且该函数的参数只有一个void * ,其实这个参数就是pthread_create()函数的第四个参数 arg。如果需要向start_routine()传递的参数有一个以上,那么需要把这些参数放到一个结构体中,然后把这个结构体对象的地址作为 arg 参数传入。
- arg:传递给 start_routine()函数的参数。一般情况下,需要将 arg 指向一个全局或堆变量,意思就是说在线程的生命周期中,该 arg 指向的对象必须存在,否则如果线程中访问了该对象将会出现错误。当然也可将参数 arg 设置为 NULL,表示不需要传入参数给start_routine()函数。
- 返回值:成功返回 0;失败时将返回一个错误号,并且参数 thread 指向的内容是不确定的。
4.2 终止线程 pthread_exit
在新线程的启动函数(线程 start 函数)new_thread_start()通过 return 返回之后,意味着该线程已经终止了
除了在线程 start 函数中执行 return 语句终止线程外,终止线程的方式还有多种,可以通过如下方式终止线程的运行:
- 线程的 start 函数执行 return 语句并返回指定值,返回值就是线程的退出码;
- 线程调用 pthread_exit()函数;
- 调用 pthread_cancel()取消线程
#include <pthread.h>
void pthread_exit(void *retval);
参数 retval 的数据类型为 void *,指定了线程的返回值、也就是线程的退出码,该返回值可由另一个线程通过调用 pthread_join()来获取;同理,如果线程是在 start 函数中执行 return 语句终止,那么 return 的返回值也是可以通过 pthread_join()来获取的
参数 retval 所指向的内容不应分配于线程栈中,因为线程终止后,将无法确定线程栈的内容是否有效;出于同样的理由,也不应在线程栈中分配线程 start 函数的返回值
4.3 回收线程 pthread_ join
在父、子进程当中,父进程可通过 wait()函数(或其变体 waitpid())阻塞等待子进程退出并获取其终止状态,回收子进程资源;而在线程当中,也需要如此,通过调用 pthread_join()函数来阻塞等待线程的终止,并获取线程的退出码,回收线程资源;pthread_join()函数原型如下所示:
#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);
thread:pthread_join()等待指定线程的终止,通过参数 thread(线程 ID)指定需要等待的线程;
4.4 取消线程 pthread_cancel
进程中的多个线程会并发执行,每个线程各司其职,直到线程的任务完成之后,该线程
中会调用 pthread_exit()退出,或在线程 start 函数执行 return 语句退出。
有时候,在程序设计需求当中,需要向一个线程发送一个请求,要求它立刻退出,我们把这种操作称为取消线程,也就是向指定的线程发送一个请求,要求其立刻终止、退出。譬如,一组线程正在执行一个运算,一旦某个线程检测到错误发生,需要其它线程退出,取消线程这项功能就派上用场了。
回收线程和取消线程有何不同?
4.5 分离线程 pthread_detach
当线程终止时,其它线程可以通过调用 pthread_join()获取其返回状态、回收线程资源,有时,程序员并不关系线程的返回状态,只是希望系统在线程终止时能够自动回收线程资源并将其移除。在这种情况下,可以调用 pthread_detach()将指定线程进行分离,也就是分离线程
#include <pthread.h>
int pthread_detach(pthread_t thread);
5 学习线程同步
为什么需要线程同步?
如何解决对共享资源的并发访问出现数据不一致的问题?
线程同步技术,来实现同一时间只允许一个线程访问该变量,防止出现并发访问的情况、消除数据不一致的问题,
5.1 互斥锁 mutex
互斥锁(mutex)又叫互斥量,从本质上说是一把锁,在访问共享资源之前对互斥锁进行上锁,在访问完成后释放互斥锁(解锁);对互斥锁进行上锁之后,任何其它试图再次对互斥锁进行加锁的线程都会被阻塞,直到当前线程释放互斥锁。如果释放互斥锁时有一个以上的线程阻塞,那么这些阻塞的线程会被唤醒,它们都会尝试对互斥锁进行加锁,当有一个线程成功对互斥锁上锁之后,其它线程就不能再次上锁了,只能再次陷入阻塞,等待下一次解锁。
互斥锁死锁
排队上厕所,进去锁门
5.2 条件变量
条件变量是线程可用的另一种同步机制。条件变量用于自动阻塞线程,知道某个特定事件发生或某个条件满足为止,通常情况下,条件变量是和互斥锁一起搭配使用的。使用条件变量主要包括两个动作:
- 一个线程等待某个条件满足而被阻塞;
- 另一个线程中,条件满足时发出“信号”
条件变量通常搭配互斥锁来使用,因为条件的检测是在互斥锁的保护下进行的,也就是说条件本身是由互斥锁保护的,线程在改变条件状态之前必须首先锁住互斥锁,不然就可能引发线程不安全的问题
生产者—消费者模型
//都是概念,具体在实践中的用法又是如何
5.3 自旋锁(了解下)
自旋锁与互斥锁很相似,从本质上说也是一把锁,在访问共享资源之前对自旋锁进行上锁,在访问完成后释放自旋锁(解锁);事实上,从实现方式上来说,互斥锁是基于自旋锁来实现的,所以自旋锁相较于互斥锁更加底层
自旋锁是一种用于多线程同步的锁机制。它是一种忙等待(busy - waiting)的锁,当一个线程尝试获取一个已经被其他线程持有的自旋锁时,该线程不会进入睡眠状态,而是会一直循环检查(自旋)锁是否被释放,直到它成功获取锁为止。
5.4 读写锁(了解下)
PS:
进程中创建的每个线程都有自己的栈地址空间,将其称为线程栈
每个线程都有自己的栈地址空间,那么每个线程运行过程中所定义的自动变量(局部变量)都是分配在自己的线程栈中的,它们不会相互干扰
可重入函数: 如果一个函数被同一进程的多个不同的执行流同时调用,每次函数调用总是能产生正确的结果(或者叫产生预期的结果),把这样的函数就称为可重入函数
(重入指的是同一个函数被不同执行流调用,前一个执行流还没有执行完该函数、另一个执行流又开始调用该函数了,其实就是同一个函数被多个执行流并发/并行调用,在宏观角度上理解指的就是被多个执行流同时调用。)