当前位置: 首页 > news >正文

linux复习

1.关于进程

1.1 概念

用户角度:进程是程序的一次执行实例,也就是正在运行的程序

内核角度:操作系统分配内存和cpu资源的实体

操作系统使用内核数据结构 + 程序的代码及数据 描述进程,Linux中对应的内核数据结构就是task_struct.

task_struct当中主要包含以下信息:

标示符: 描述本进程的唯一标示符,用来区别其他进程。
状态: 任务状态,退出代码,退出信号等。
优先级: 相对于其他进程的优先级。
程序计数器(pc): 程序中即将被执行的下一条指令的地址。
内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针。
上下文数据: 进程执行时处理器的寄存器中的数据。

ps -aux或者ps -axj可以查看系统中的所有进程,u和j是选择显示的格式不同。

同时我们在  /proc 目录中存在一个个子目录,32452,12356这些子目录就是存储了进程运行的一些相关信息,里面存在cwd和exe两个文件

1.2 fork

fork是系统调用接口,用于创建进程。在它之后,出现两个进程,原始的数据和 fork之后的代码共享,但其返回值id在两个进程中各存储 一份,我们利用id==0来进行执行分流。

1.3 进程状态

task_struct在cpu的runqueue中排队,则为运行状态R。当然它们也可以去其他资源下面排队(阻塞)。

Z状态:子进程的pcb的退出信息未被父进程读取。僵尸状态的存在是必要的,因为进程被创建的目的就是完成某项任务,那么当任务完成的时候,调用方是应该知道任务的完成情况的,所以必须存在僵尸状态,使得调用方得知任务的完成情况. 僵尸进程的退出信息被保存在task_struct中,僵尸状态一直不退出,那么PCB就一直需要进行维护,出现内存泄漏。

孤儿进程:父进程先退出,子进程会被1号进程领养

1.4 环境变量

环境变量一般是指在操作系统中用来指定操作系统运行环境的一些变量。

常见环境变量

  • PATH: 指定命令的搜索路径。
  • HOME: 指定用户的主工作目录(即用户登录到Linux系统中的默认所处目录)。
  • SHELL: 当前Shell,它的值通常是/bin/bash。

我们使用echo $PATH可以查看环境变量中系统默认路径的值,env查看所有环境变量,set查看所有环境和本地变量

进程的task_struct中有一个字段为 mm_struct *,  mm_struct为进程虚拟地址空间,这个地址空间的最高地址处是内核空间,下面就是环境变量,然后是命令行参数和栈区

环境变量是OS开辟空间所存储的带有特殊功能的变量,它会传递给子进程。

1. 我们机器启动时,电脑会自动读取根目录下面和用户目录下面存在在某位置的profile文件,这两个文件分别存放了系统环境变量和用户环境变量,这时操作系统里面就有了环境变量。而我们后面在电脑所执行的进程中,都会包含这些环境变量。

2. 当我们在windows 双击某应用程序时,这个软链接(快捷方式)会存放这个程序的具体路径,如:C:\Program Files (x86)\Google\Chrome\Application\chrome.exe,然后该程序就启动了。

但程序的启动除了在当前目录下进行查找这个可执行程序(命令),它们还会去$PATH所指定的目录下去查找该命令是否存在。 因此我们如果想在任意位置执行test.exe,可以sudo cp test.exe /usr/bin,  或者 export PATH=$PATH: /xwy/cpp/code1.

main函数有三个参数,argc,argv,envp,其中envp就是环境变量,我们还可以使用const char*= getenv("USER")获取和使用环境变量。

1.5 进程切换

进程的tastk_struct 中除了pid,进程状态,进程优先级,进程地址空间(mm_struct*),还要有struct thread_struct (软件上下文),硬件上下文(tss_struct),它保存进程的一些寄存器数据(包括PC和页表基址寄存器),它们改变了,那所取出的下一条指令的地址也改变了,同时映射到物理内存的数据也改变了!!! 上下文切换 中,tss_struct 主要由 CPU 自动处理,而 thread_struct则由操作系统通过软件来管理。

1.6 进程调度

runqueue,两个指针,active和expired,两个struct ,arr[0]和arr[1],它们当中有nr_active记录进程数量,有bitmap[5],记录该优先级下的队列是否存在进程,queue[140],0-100不使用,每个queue都连接一定数量task_struct.

只用40个优先级在使用,对应了nice值为[-20,19], 默认优先级为80.

1.7 进程地址空间

进程task_struct当中有一个结构体指针存储的是mm_struct的地址。

父进程创建子进程后的task_struct和mm_struct:

1.8 进程相关接口

创建进程:fork(),他会拷贝原有进程的mm_struct和页表,同时在 页表中  把原有进程的数据的权限改为已读,当有一方写入时,发生缺页中断,OS介入,发生写时拷贝。

进程终止 :exit(2),_exit(2)

exit函数会执行一些清理操作,包括调用所有已注册的终止处理函数(如atexit注册的函数),关闭所有打开的标准流(如stdout, stderr等),释放内存,它屏蔽了底层系统调用的不同。

_exit为系统调用函数直接将控制权返回给父进程,并传递一个退出状态码。

而return则是将返回值给调用者,标志着一个执行流的结束,而我们在main()函数中return时候,其上层的调用者最终还是会调用exit()去终止进程。当我们在子进程中 return时,也可以返回给父进程返回值,但它不会进行清理操作。

进程等待: 进程结束后会给父进程返回一个 int,int中0-16位有意义,最后七位代表信号编号,中间八位就是进程所得到的返回值,(status>>8)&0xff即可以得到返回值。

pid_t wait(int *status);   //阻塞式的随机等待一个子进程

pid_t  waitpid(id,status,  WNOHANG); //等待指定pid进程,可设置非阻塞的等待

进程替换:execl (path,argv,...); 执行路径,执行谁,怎么执行,以null结尾。调用失败返回-1,调用成功,原来进程后面的代码不再执行。

2.关于文件

关于操作系统对文件的管理,也是先描述,再组织,文件与进程息息相关,进程可以打开多个文件,我们一般所指的就是内存文件。

struct file_struct

进程的结构中,除了mm_struct*,thread_struct*, 还需要有file_struct*,file struct结构体中除了一些属性外,还有一个指针数组,file* fd[ ],各个打开的文件描述符就依次存放在这个数组里面。

struct file

file结构体它也有很多字段,如指向文件操作函数的指针file_op* (每种类型文件的该结构体不同,内核会根据文件类型调用不同的操作 ,如块设备、字符设备)、文件标志、文件权限,文件的读写偏移量等。  它帮我们屏蔽了不同设备(如磁盘读写,显示器显示等等)的差异,我们使用read,write,它会调用不同的底层接口进行处理,在我们用户看来都是对文件的操作。

Linux下用户与内核中的交互都是通过struct file_struct和struct file。键盘显示都被看作虚拟文件,而其余硬件设备也会打开时也会打开一个 struct file。

如下:当进程打开log.txt文件时,我们需要先将该文件从磁盘当中加载到内存,形成对应的struct file,将该struct file连入文件双链表,并将该结构体的首地址填入到fd_array数组当中下标为3的位置,使得fd_array数组中下标为3的指针指向该struct file,最后返回该文件的文件描述符给调用进程即可。

关于网络部分 : struct socket中包含struct file* 。应用程序通过 socket() 创建一个套接字时,内核会返回一个文件描述符,这个文件描述符指向一个内核中的 struct file 结构,并且可以通过 struct file 进一步操作 struct socket,struct file有一个file_op*,这些操作表中的函数实现了套接字的读取、写入、关闭等操作,其中的read = sock_read,通常对应于 recv() 系统调用。

网络中数据的发送:当应用程序调用套接字的 send()write() 时,内核将数据交给协议栈处理。数据会根据协议(如 TCP 或 UDP)进行分段、加上适当的头信息(如 IP 地址和端口号),最终通过网卡接口发送出去,其中网卡驱动对应的inode会给数据源mac地址和目的mac地址 。

其还有一个字段 f_inode:指向文件的 inode 结构体的指针, 每个文件、目录、符号链接等对象在文件系统中都有一个唯一的 inode,它是在分区内唯一,inode是一个文件的属性集合. Linux 内核为套接字创建了 inode 结构。在套接字的临时 inode 中,存储的是套接字的元数据(如类型、协议、端口号等)。

struct inode

inode 结构体在 Linux 中代表了文件的元数据,它包括了文件的类型、权限、大小、时间戳、以及对应的数据块位置(采用多级索引),通过这个inode可以找到其文件的内容。

磁盘的扇区(512B)被OS当成一个个的块组,这些块组又被分成inode table和data blocks。struct inode将内存和磁盘建立了联系。

相关接口

文件重定向接口:dup2(oldfd,newfd),调用之后全部全部变成old_fd.

读写相关接口 open,close,read,write

缓冲区

我们常说的缓冲区指的是C语言(C++)缓冲区,它存放在FILE结构体中,可以将其理解为一个结构体或者数组,当存储到一定量或者到了一定时间,缓冲区的数据会刷新到OS的缓冲区中(其存在于 struct file中),后面通过inode块刷新到数据块之中。

子进程会继承父进程的缓冲区,所以有时会出现printf()在fork之前,数据却打印了两份的现象。

目录

目录也是文件,有自己的inode,它的内容是文件名和inode编号的对应,当我们打开文件时,操作系统会进行文件路径的解析,依次打开相关的目录文件,读取其中信息,直到我们打开最终的文件,就是把这个文件的 inode加载进内存,同时把file结构体放入内存,并填写对应打开进程的文件描述符表。

软硬链接

ln -s test.c link.soft, 软链接分配新的inode,其文件内容指向目标文件的路径

硬链接则是将该文件名和目标inode 的对应关系放在目录下。每个目录创建后,该目录下默认会有两个隐含文件...,它们分别代表当前目录和上级目录,因此这里创建的目录有两个名字,一个是dir另一个就是该目录下的.所以刚创建的目录硬链接数是2,如果该目录下再创建一个子目录,则硬链接则会+1

为什么说linux下一切皆文件

1. 文件系统抽象

在 Linux 中,文件系统不仅仅包括磁盘上的文件,也包括了许多其他资源。无论是常规的磁盘文件,还是特殊设备(如硬盘、终端、网络套接字等),都通过文件系统来进行管理和访问。

设备文件:硬件设备(如硬盘、键盘、显示器、打印机等)通过设备文件进行表示,通常位于 /dev 目录下。例如,/dev/sda 表示第一个硬盘,/dev/ttyS0 表示串口设备。管道和套接字:用于进程间通信的机制,它们也被视为文件,在文件系统中以特殊文件的形式存在(如 /tmp 中的临时文件,/proc 中的进程信息内存:内存使用的统计信息,主要通过 /proc/meminfo 文件提供

2.统一接口

对各种资源的管理:这些操作通常通过系统调用 read()write()open()close() 等来完成,而这些系统调用在内核内部对不同类型的资源进行了适配和处理。开发者只需要关心如何操作文件描述符,而不需要区分文件、设备或网络等资源的具体实现。

3. 文件描述符

在 Linux 中,打开的每个文件、设备或资源都有一个唯一的文件描述符(file descriptor)。通过文件描述符,用户可以操作不同的资源。文件描述符在进程的上下文中是唯一的,内核通过一个系统调用接口(如 open())来管理文件描述符的分配和回收。

3.linux内存管理

1. 虚拟内存

虚拟内存是 Linux 内核内存管理的基础。每个进程在执行时,操作系统为其分配虚拟内存空间,这使得每个进程看起来拥有独立的内存。虚拟内存通过内存映射机制将虚拟地址与物理内存地址(或磁盘上的交换空间)关联起来。它的主要特点包括:

  • 地址空间隔离:每个进程有自己的虚拟地址空间,操作系统负责将其映射到实际的物理内存上。
  • 页(Page):虚拟内存被划分为固定大小的块,通常为 4 KB(虽然支持更大的页大小,如 2MB 或 1GB)。每个进程的虚拟地址空间都被划分为多个页。
  • 分页(Paging):当进程访问虚拟地址时,内核会使用页表来将虚拟地址映射到物理地址。如果虚拟页不在物理内存中,内核会进行页面交换(页调度)。

2. 页表

页表是虚拟内存和物理内存之间的映射表。每个进程都有一个页表,它记录了进程的虚拟地址与物理地址之间的映射关系。页表项包含了每个虚拟页的物理页框号(physical page frame number)以及访问权限等信息。

  • 三级页表结构:在 64 位系统中,Linux 使用三级页表(L1, L2, L3),每一层页表的大小和结构可以根据架构的不同进行调整。
  • 多级页表:使用多级页表结构(例如 4 级页表)以减少每个进程的页表大小,减少内存消耗。

3. 物理内存管理

物理内存是实际的计算机硬件内存。内核需要高效地管理这些内存资源。物理内存的管理方式主要包括:

  • 内存分配:Linux 使用 buddy allocator 来管理物理内存。它通过将内存划分为不同大小的块来高效地分配和回收内存。buddy allocator 的核心思想是通过合并和分割内存块来最大化利用内存。Linux 内核将物理内存划分为多个大小为 2 的幂次的页面(通常是 4KB),并使用 Buddy Allocator 来高效地分配和回收内存页。
  • 内存区域划分:Linux 将物理内存划分为不同的区域,主要包括:
    • ZONE_DMA:用于低地址区的内存,通常与设备直接内存访问(DMA)相关,效率高。
    • ZONE_NORMAL:普通内存区域,操作系统和用户进程通常使用的内存。
    • ZONE_HIGHMEM:高端内存区域,主要用于 32 位系统,系统无法直接访问的内存区域,通常需要通过特殊机制(如页表映射)才能使用。
  • Slab 分配器:用于高效分配和释放内存的小块,主要用于内核对象的分配,例如进程控制块(PCB)、文件描述符等。

4. 交换空间

当物理内存不足时,Linux 内核使用交换空间(将部分内存页面换出到磁盘上。交换空间是磁盘的一块区域,它可以是一个专门的交换分区或一个交换文件。当系统需要更多内存时,内核会选择一些不活跃的页面交换出去,腾出内存供当前进程使用。

  • 交换空间的使用:当内存页被交换到磁盘时,系统会将这些页面标记为“不在内存中”,如果再次访问这些页面,内核会将它们从交换空间调回内存。
  • 页面交换的策略:内核使用 LRU(Least Recently Used,最少最近使用)算法来管理交换页面。LRU 算法会优先交换那些长时间未被使用的内存页。

5. 内存映射(Memory Mapping)

内存映射是将文件或设备直接映射到进程的虚拟内存空间中,这样程序就可以直接操作这些文件或设备的数据,而不需要通过传统的读取/写入接口。内存映射主要用于:

  • 共享内存:多个进程可以共享同一块物理内存,进程间通信。
  • 内存映射文件:将文件映射到进程的地址空间,从而使得程序能够像访问内存一样访问文件。Linux 中使用 mmap() 系统调用来实现内存映射,程序可以直接在内存中操作文件,能显著提高读写效率。
  • 映射设备:设备可以通过内存映射映射到进程的虚拟内存空间,允许直接与硬件交互。

6. 内存页面回收

当系统内存资源紧张时,Linux 内核会回收一些不再使用的内存页面。页面回收通常通过以下几种方式完成:

  • 清理页面:如果内存页被修改,但尚未被写回磁盘,内核会先将这些页写回磁盘。
  • 懒惰写回:内核会定期将脏页(已修改但未写回的页面)写回磁盘。
  • 直接回收:对于长期未被访问的页面,内核会直接将其回收并释放内存。

7. 内存保护与访问控制

Linux 通过内存保护来确保进程之间互相隔离,防止非法访问其他进程的内存。内核使用页表项中的标志位来控制访问权限(如读、写、执行等)。常见的内存保护机制包括:

  • 只读内存:防止进程修改某些特定内存区域。
  • 执行保护:防止执行某些特定区域的代码(如堆栈区、数据区等)。
  • 堆栈保护:为了防止堆栈溢出,内核会为堆栈设置特殊的保护页。

8. 内存使用统计

Linux 内核还提供了内存使用的统计信息,主要通过 /proc/meminfo 文件提供。这些统计信息包括:

  • 总内存:系统的总物理内存大小。
  • 可用内存:系统当前未被使用且可用于进程的内存。
  • 缓存和缓冲区:内核为提高性能而缓存的内存区域。
  • 交换空间:磁盘上用于交换的空间大小及其使用情况。

用户和管理员可以通过这些信息了解系统的内存使用情况,调整系统配置,避免内存不足等问题。

4.动静态库

1.基本原理

动静态库是可执行程序的半成品。

对于频繁使用的test1.c,test2.c,test3.c,我们可以把它打包在一起,供main1.c,main2.c调用。

一堆源文件和头文件最终变成一个可执行程序需要经历以下四个步骤:

预处理: 完成头文件展开、去注释、宏替换、条件编译等,最终形成xxx.i文件。
编译: 完成词法分析、语法分析、语义分析、符号汇总等,检查无误后将代码翻译成汇编指令,最终形成xxx.s文件。
汇编: 将汇编指令转换成二进制指令,最终形成xxx.o文件。
链接: 将生成的各个xxx.o文件进行链接,进行符号汇总解析,段合并,地址分配(生成绝对地址),代码和数据中地址的重定位并生成最终的elf可执行程序,(.text,.data,.bss会分配这些段在可执行文件中的地址)

静态库采用绝对编址,会把所有的函数和变量都形成一个统一的绝对地址,链接过程中链接器根据程序中使用的符号,提取库中仅与这些符号相关的部分加载进可执行程序中。

动态库采用绝对编制和相对编制结合的方式。

  • 动态库中函数和变量的符号在编译时会记录一个固定的偏移地址
  • 链接器在运行时将这些符号的引用解析为库中的绝对地址
  • 其他函数和变量的符号直接形成一个绝对地址。

如果我们使用printf函数,编译器编译时只需要记录这个函数在C标准库的偏移量(0x11223344),等到实际运行时会根据库函数实际加载的地址(0x22334455),加上之前记录的偏移量,找到该函数的具体地址。

进程地址空间实际由编译器+页表+CPU共同完成。

2.查看动静态库

ldd a.out

可以看到a.out链接的动态库,其中的libc.so.6就是该可执行程序所依赖的库文件,我们通过ls命令可以发现libc.so.6实际上只是一个软链接。

libc-2.17.so实际上就是一个共享的目标文件库,准确来说,这还是一个动态库。

  • 在Linux当中,以.so为后缀的是动态库,以.a为后缀的是静态库。
  • 在Windows当中,以.dll为后缀的是动态库,以.lib为后缀的是静态库。

3.动静态库的制作

第一步:让所有源文件生成对应的目标文件 

静态库:gcc -c add.c 和 gcc -c sub.c; 编译时的 -c选项就是让这个.c文件走完预处理,编译汇编三个过程。

动态库:gcc -fPIC -c add.c 和 gcc -fPIC -c sub.c;

第二步: 打包生成静态库文件

静态库:ar -rc libcal.a add.o sub.o

动态库 :gcc -shared  -o libcal.a add.o sub.o

第三步:将头文件和生成的静态库组织起来

在这里我们可以将add.hsub.h这两个头文件放到一个名为include的目录下,将生成的静态库文件libcal.a放到一个名为lib的目录下,然后将这两个目录都放到mathlib下,此时就可以将mathlib给别人使用了。

使用:

方法一:使用gcc编译main.c生成可执行程序时需要携带三个选项:

  • -I:指定头文件搜索路径。
  • -L:指定库文件搜索路径。
  • -l:指明需要链接库文件路径下的哪一个库。

示例: gcc main.c   -I  ./mathlib/include   -L./mathlib/lib    -lcal

方法二:把头文件和库文件拷贝到系统路径下

然后我们使用时只需要-l:指明需要链接库文件路径下的哪一个库即可。

除了以上这些,对于动态库,则多一个步骤,因为我们使用-I-L-l这三个选项都是在编译生成可执行程序期间编译器我们使用的头文件和库文件在哪里以及是谁,但是当生成的可执行程序生成后就与编译器没有关系了,此后该可执行程序运行起来后,操作系统找不到该可执行程序所依赖的动态库在哪里,我们可以使用ldd a.out命令进行查看。

方法一:拷贝.so文件到系统共享库路径下

既然系统找不到我们的库文件,那么我们直接将库文件拷贝到系统共享的库路径下,这样一来系统就能够找到对应的库文件了。如:sudo cp mlib/lib/libcal.so /lib64

方法二:更改LD_LIBRARY_PATH

export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/home/xwy/BasicIO/testlib/project/mlib/lib
,LD_LIBRARY_PATH是程序运行动态查找库时所要搜索的路径,我们只需将动态库所在的目录路径添加到LD_LIBRARY_PATH环境变量当中即可。

方法三:配置/etc/ld.so.conf.d/

动态库执行起来之后依然需要 操作系统找得到对应动态库的位置并加载进内存。

5.进程间通信

我们讲匿名管道和命名管道两种进程间通信的方式,还有POSIX的进程(线程通信)。systemV那三种共享内存,消息队列,信号量只讲原理。

管道定义:我们把从一个进程连接到另一个进程的数据流称为一个“管道”。

例如,统计我们当前使用云服务器上的登录用户个数。  who|wc -l

who命令和wc命令都是两个程序,当它们运行起来后就变成了两个进程,who进程通过标准输出stdout 将数据打到“管道”当中,wc进程再通过标准输入从“管道”当中读取数据,至此便完成了数据的传输,进而完成数据的进一步加工处理。

1. 匿名管道

进程间通信的本质就是,让不同的进程看到同一份资源,使用匿名管道实现进程间通信的原理就是,让两个具有血缘关系的进程先看到同一份被打开的文件资源,然后两进程就可以对该文件进行写入或是读取操作,进而实现进程间通信。

这张图非常的重要,不仅说明了匿名管道的通信原理。还可以说明进程与文件的联系,同时不同类型的inode有不同的结构。网络套接字socket,磁盘,键盘鼠标都有它特定的inode结构和操作方法operator* op(存在file结构中),它决定最终放在文件缓冲区的内容是否刷到特定的物理设备上,以及何种物理设备(网卡,键盘,屏幕)上面。

2. 相关接口

int pipe(int pipefd[2]);

pipe函数的参数是一个输出型参数,数组pipefd用于返回两个指向管道读端和写端的文件描述符,pipe函数调用成功时返回0,调用失败时返回-1。

这个系统调用执行后OS会打开一个“文件”,然后返回读写两端的文件描述符,当fork()之后,父子进程就拿到对同一个文件的两个文件描述符。与以读写方式两次open统一文件不同的是

1.它不需要指定文件名

2.父子进程任何一个进行write时它不会发生写时拷贝。

int pipe2(int pipefd[2], int flags);

额外的 flags 参数pipe2  允许用户设置管道的标志,最常见的是 O_CLOEXEC,可以在管道创建时设置文件描述符在 exec 系统调用时自动关闭。 pipe 创建的管道文件描述符默认没有设置 O_CLOEXEC 标志,这意味着如果后续调用 exec 时,这些文件描述符会被继承。

当使用 O_NONBLOCK 标志创建管道时,管道的读写操作变成非阻塞模式。如:管道为空,read读取会直接返回-1,错误码被设置为EAGAIN或 EWOULDBLOCK。管道满,write返回-1,错误码被设置为EAGAIN或 EWOULDBLOCK

// 两种标志的使用示例
int pipefd[2];
if (pipe2(pipefd, O_CLOEXEC) == -1) {perror("pipe2");exit(EXIT_FAILURE);
}
int pipefd[2];if (pipe2(pipefd, O_NONBLOCK) == -1) {perror("pipe2");exit(EXIT_FAILURE);
}

写相关代码记住fd的关闭以及父进程的 wait。

3. 管道的特点

1、管道内部自带同步与互斥机制。管道在同一时刻只允许一个进程对其进行写入或读取

2、管道的生命周期随进程。

3、管道提供的是流式服务。不同于有明确分割的数据报服务

4、管道是半双工通信的。可以边写边读,但是读和写已经定义好了,属于半双工服务。

如果管道没有读端,或者读端没有读取数据,在阻塞模式下,管道的写操作会阻塞,直到有一个进程打开管道的读端并开始读取数据。在非阻塞模式下,写操作失败,设置 errnoEPIPE

4. 管道的四种特殊情况

1. 写端进程不写,读端进程一直读,那么此时会因为管道里面没有数据可读,对应的读端进程会被挂起,直到管道里面有数据后,读端进程才会被唤醒。
2. 读端进程不读,写端进程一直写,那么当管道被写满后,对应的写端进程会被挂起,直到管道当中的数据被读端进程读取后,写端进程才会被唤醒。
3.写端进程将数据写完后将写端关闭,那么读端进程将管道当中的数据读完后,就会继续执行该进程之后的代码逻辑,而不会被挂起。
4. 读端进程将读端关闭,而写端进程还在一直向管道写入数据,那么操作系统会将写端进程杀掉。

讲第四种,子进程在write,父进程直接  close(fd[0]); 父进程关闭读端,子进程会收到13号信号SIGPIPE从而杀死子进程。

2. 命名管道

1. 使用命令创建命名管道

我们可以使用mkfifo命令创建一个命名管道。

mkfifo fifo

fifo的文件类型是p,即管道文件,然后我们开cat >fifo不断读取该管道文件(cat fifo也可以,cat命令本身是读取文件内容),然后我们在另一终端执行脚本 while : ; do echo "hello"; sleep 1; done >> fifo,可以看到 读端显示hello

2. 相关接口

int mkfifo(const char *pathname, mode_t mode);

mkfifo函数的第一个参数是pathname,表示要创建的命名管道文件。

mkfifo函数的第二个参数是mode,表示创建命名管道文件的默认权限。

如果将mode设置为0666,则命名管道文件创建出来的权限如下:rw-rw-r--,因为mask为0002,因此需要在创建文件前使用umask函数将文件默认掩码设置为0。

用命名管道实现serve&client通信

实现服务端(server)和客户端(client)之间的通信之前,我们需要先让服务端运行起来,我们需要让服务端运行后创建一个命名管道文件,然后再以读的方式打开该命名管道文件,之后服务端就可以从该命名管道当中读取客户端发来的通信信息了。

共用头文件:

//comm.h
#pragma once#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <string.h>
#include <fcntl.h>#define FILE_NAME "myfifo" //让客户端和服务端使用同一个命名管道

服务端的代码如下:

//server.c
#include "comm.h"int main()
{umask(0); //将文件默认掩码设置为0if (mkfifo(FILE_NAME, 0666) < 0){ //使用mkfifo创建命名管道文件perror("mkfifo");return 1;}int fd = open(FILE_NAME, O_RDONLY); //以读的方式打开命名管道文件if (fd < 0){perror("open");return 2;}char msg[128];while (1){msg[0] = '\0'; //每次读之前将msg清空//从命名管道当中读取信息ssize_t s = read(fd, msg, sizeof(msg)-1);if (s > 0){msg[s] = '\0'; //手动设置'\0',便于输出printf("client# %s\n", msg); //输出客户端发来的信息}else if (s == 0){printf("client quit!\n");break;}else{printf("read error!\n");break;}}close(fd); //通信完毕,关闭命名管道文件return 0;
}

对于客户端来说,因为服务端运行起来后命名管道文件就已经被创建了,所以客户端只需以写的方式打开该命名管道文件,之后客户端就可以将通信信息写入到命名管道文件当中,进而实现和服务端的通信。

客户端的代码如下:

//client.c
#include "comm.h"int main()
{int fd = open(FILE_NAME, O_WRONLY); //以写的方式打开命名管道文件if (fd < 0){perror("open");return 1;}char msg[128];while (1){msg[0] = '\0'; //每次读之前将msg清空printf("Please Enter# "); //提示客户端输入fflush(stdout);  //这里我们也可以用dup2()接口//从客户端的标准输入流读取信息ssize_t s = read(0, msg, sizeof(msg)-1);if (s > 0){msg[s - 1] = '\0';//将信息写入命名管道write(fd, msg, strlen(msg));}}close(fd); //通信完毕,关闭命名管道文件return 0;
}

命令行当中的管道(“|”)到底是匿名管道还是命名管道呢?

下面通过管道(“|”)连接了三个进程,通过ps命令查看这三个进程可以发现,这三个进程的PPID是相同的,也就是说它们是由同一个父进程创建的子进程。

3. system V进程间通信

管道通信本质是基于文件的,也就是说操作系统并没有为此做过多的设计工作,system V IPC是操作系统特地设计的进程间通信方式。但是不管怎么样,它们的本质都是一样的,都是在想尽办法让不同的进程看到同一份由操作系统提供的资源。

system V共享内存和system V消息队列是以传送数据为目的的,而system V信号量是为了保证进程间的同步与互斥而设计的,虽然system V信号量和通信好像没有直接关系,但属于通信范畴。

1.共享内存的基本原理

共享内存让不同进程看到同一份资源的方式就是,在物理内存当中申请一块内存空间,然后将这块内存空间分别与各个进程各自的页表之间建立映射,再在虚拟地址空间当中开辟空间并将虚拟地址填充到各自页表的对应位置,使得虚拟地址和物理地址之间建立起对应关系,至此这些进程便看到了同一份物理内存,这块物理内存就叫做共享内存。shmget函数,应该为他分配一个key,标识这个共享内存。

2. 消息队列的基本原理

消息队列实际上就是在系统当中创建了一个队列,队列当中的每个成员都是一个数据块,这些数据块都由类型和信息两部分构成,两个互相通信的进程通过某种方式看到同一个消息队列,这两个进程向对方发数据时,都在消息队列的队尾添加数据块,这两个进程获取数据块时,都在消息队列的队头取数据块。msgget,也需要一个key。

3.信号量

信号量就是用来保护临界区的,信号量分为二元信号量和多元信号量。当进程A申请访问共享内存资源时,如果此时sem为1(sem代表当前信号量个数),则进程A申请资源成功,此时需要将sem减减。信号量也需要一个key。

共享内存、消息队列以及信号量,虽然它们内部的属性差别很大,但是维护它们的数据结构的第一个成员确实一样的,都是ipc_perm类型的成员变量。

这样设计的好处就是,在操作系统内可以定义一个struct ipc_perm类型的数组,此时每当我们申请一个IPC资源,就在该数组当中开辟一个这样的结构。

会话与服务

建立与控制终端连接的会话首进程被称为控制进程。一个会话中的几个进程组可被分为一个前台进程组以及一个或多个后台进程组。所以一个会话中,应该包括控制进程(会话首进程),一个前台进程组和任意多个后台进程组。如果我们再xshell连接服务器终端,终端将会有一个控制进程,当我们断开连接时,服务器终端上的控制进程会收到一号信号SIGHUP而终止控制进程。

守护进程也称精灵进程(Daemon),是运行在后台的一种特殊进程,它独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。TPGID一栏写着-1的都是没有控制终端的进程,也就是守护进程,即服务

我们创建守护进程时可以直接调用daemon接口进行创建,daemon函数的函数原型如下:

int daemon(int nochdir, int noclose);

参数说明:

  • 如果参数nochdir为0,则将守护进程的工作目录该为根目录,否则不做处理。
  • 如果参数noclose为0,则将守护进程的标准输入、标准输出以及标准错误重定向到/dev/null,否则不做处理。

6.信号相关

常见信号


1    SIGHUP    如果终端接口检测到一个连接断开,则会将此信号发送给与该终端相关的控制进程,该信号的默认处理动作是终止进程。
2    SIGINT    当用户按组合键(一般采用Ctrl+C)时,终端驱动程序产生此信号并发送至前台进程组中的每一个进程,该信号的默认处理动作是终止进程。
3    SIGQUIT    当用户按组合键(一般采用Ctrl+\)时,终端驱动程序产生此信号并发送至前台进程组中的每一个进程,该信号不仅终止前台进程组,同时会产生一个core文件。
6    SIGABRT    调用abort函数是产生此信号,进程异常终止,同时会产生一个core文件。
8    SIGFPE    此信号表示一个算术运算异常,比如除0、浮点溢出等,该信号的默认处理动作是终止进程,同时产生一个core文件。
9    SIGKILL  该信号不能被捕捉或忽略,可以杀死任一进程的可靠方法。
11    SIGSEGV    指示进程进行了一次无效的内存访问(比如访问了一个未初始化的指针),该信号的默认处理动作是终止进程并产生一个core文件。
13    SIGPIPE    如果在管道的读进程已终止时对管道进行写入操作,则会收到此信号,该信号的默认处理动作是终止进程。 要想不产生僵尸进程:父进程调用signal或sigaction函数将SIGCHLD信号的处理动作设置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。
14    SIGALRM    当用alarm函数设置的定时器超时时产生此信号,或由setitimer函数设置的间隔时间已经超时时也产生会此信号。
17    SIGCHLD    在一个进程终止或停止时,SIGCHLD信号被发送给其父进程。按系统默认,将忽略此信号。如果父进程希望被告知其子进程的这种状态改变,则应捕捉此信号。信号捕捉函数中通常要调用一种wait函数以取得子进程ID及其终止状态。
19    SIGSTOP    这时一个作业控制信号,该信号用于停止一个进程,类似于交互停止信号(SIGTSTP),但是该信号不能被捕捉或忽略。
20    SIGTSTP    交互停止信号,当用户按组合键(一般采用Ctrl+Z)时,终端驱动程序产生此信号并发送至前台进程组中的每一个进程。

信号是进程之间事件异步通知的一种方式,属于软中断。

信号的原理

我们都知道进程控制块本质上就是一个结构体变量,而对于信号来说我们主要就是记录某种信号是否产生,因此,PCB中有 一个32位的位图来记录信号是否产生。这也就是pending位图:

还有一个block位图,以及handler注册表,它们来构成信号的阻塞,以及捕获信号时的处理方法。

信号产生的四种方法

1. 组合按键产生,CTRL+c

2.软件中断产生,管道中的13号信号。

3. 由硬件异常产生信号。硬件上面的信息也会立马被操作系统识别,有状态码寄存器

4.系统函数产生。kill(),absort,alarm,raise等函数可以产生信号,alarm函数的作用就是,让操作系统在seconds秒之后给当前进程发送SIGALRM信号,SIGALRM信号的默认处理动作是终止进程。

信号集的使用

#include <stdio.h>
#include <unistd.h>
#include <signal.h>void printPending(sigset_t *pending)
{int i = 1;for (i = 1; i <= 31; i++){if (sigismember(pending, i)){printf("1 ");}else{printf("0 ");}}printf("\n");
}
int main()
{// signal(2,handler); 注册信号的自定义方法sigset_t set, oset;sigemptyset(&set);sigemptyset(&oset);sigaddset(&set, 2); //SIGINTsigprocmask(SIG_SETMASK, &set, &oset); //阻塞2号信号sigset_t pending;sigemptyset(&pending);while (1){sigpending(&pending); //获取pendingprintPending(&pending); //打印pending位图(1表示未决)sleep(1);}return 0;
}

用户空间与内核空间

每个进程都能够看到操作系统,可以请求 OS去执行对应的系统调用。其实本质式只有OS的代码在运行,当有新进程进来时,OS统一管理,分配运行队列,同时该进程运行时间片到了,会执行进程的切换。

信号的捕捉  signal()和signaction()

进程收到信号之后,并不是立即处理信号,而是在从内核态切换回用户态的时候。此时还是内核态

如果待处理信号的处理动作是默认或者忽略:

如果待处理信号是自定义捕捉的:

7. linux下的线程

线程概念

进程并不是通过task_struct来衡量的,除了task_struct之外,一个进程还要有进程地址空间、文件、信号等等,合起来称之为一个进程。linux下的线程只是多创建了一个task_struct,而其它指向同一个file_struct结构和mm_struct结构,而信号位图也是相同的。只是在进程内部多了一个可以被cpu调度的执行流。

线程共享进程数据,因此所谓的代码段(Text Segment)、数据段(Data Segment)都是共享的:但也拥有自己的一部分数据:

  • 线程ID。
  • 一组寄存器。(存储每个线程的上下文信息)
  • 栈。(每个线程都有临时的数据,需要压栈出栈)
  • errno。(C语言提供的全局变量,每个线程都有自己的)

原生线程库pthread

在Linux中,站在内核角度没有真正意义上线程相关的接口,但是站在用户角度,当用户想创建一个线程时更期望使用thread_create这样类似的接口,而不是vfork函数,因此系统为用户层提供了原生线程库pthread。

原生线程库实际就是对轻量级进程的系统调用进行了封装,在用户层模拟实现了一套线程相关的接口,因此对于我们来讲,在Linux下学习线程实际上就是学习在用户层模拟实现的这一套接口,而并非操作系统的接口。

下面我们创建五个新线程后让这五个新线程将自己进行分离,那么此后主线程就不需要在对这五个新线程进行join等待了:

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>void* Routine(void* arg)
{pthread_detach(pthread_self());char* msg = (char*)arg;int count = 0;while (count < 5){printf("I am %s...pid: %d, ppid: %d, tid: %lu\n", msg, getpid(), getppid(), pthread_self());sleep(1);count++;}pthread_exit((void*)6666);
}
int main()
{pthread_t tid[5];for (int i = 0; i < 5; i++){char* buffer = (char*)malloc(64);sprintf(buffer, "thread %d", i);pthread_create(&tid[i], NULL, Routine, buffer);printf("%s tid is %lu\n", buffer, tid[i]);}while (1){printf("I am main thread...pid: %d, ppid: %d, tid: %lu\n", getpid(), getppid(), pthread_self());sleep(1);}return 0;
}

LWP和PID

当我们用ps axj命令查看当前进程的信息时,虽然此时该进程中有两个线程,但是我们看到的进程只有一个,因为这两个线程都是属于同一个进程的。而使用ps -aL命令,可以显示当前的轻量级进程。

LWP(Light Weight Process)就是轻量级进程的ID,可以看到显示的两个轻量级进程的PID是相同的,因为它们属于同一个进程。

注意: 在Linux中,应用层的线程与内核的LWP是一一对应的,实际上操作系统调度的时候采用的是LWP,而并非PID,只不过我们之前接触到的都是单线程进程,其PID和LWP是相等的,所以对于单线程进程来说,调度时采用PID和LWP是一样的。

线程ID及进程地址空间布局

线程的管理由线程库来实现,它需要有线程上下文,自己的线程栈以及线程局部存储。而线程ID本质就是进程地址空间共享区上的一个虚拟地址,同一个进程中所有的虚拟地址都是不同的,因此可以用它来唯一区分每一个线程,也可以用它来访问到各个线程

Linux线程互斥

pthread_mutex_t mutex;

pthread_mutex_init( ) ;

pthread_mutex_lock

例子:抢票程序中多个线程参与抢票,最后抢票数量为负数,因为--tickets包含三步操作,不是原子的。

要解决上述抢票系统的问题,需要做到三点:

  • 代码必须有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
  • 如果多个线程同时要求执行临界区的代码,并且此时临界区没有线程在执行,那么只能允许一个线程进入该临界区。
  • 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。

要做到这三点,本质上就是需要一把锁,Linux上提供的这把锁叫互斥量。

正确的抢票代码:

#include <stdio.h>
#include <unistd.h>
#include <pthread.h>int tickets = 1000;
pthread_mutex_t mutex;
void* TicketGrabbing(void* arg)
{const char* name = (char*)arg;while (1){pthread_mutex_lock(&mutex);if (tickets > 0){usleep(100);printf("[%s] get a ticket, left: %d\n", name, --tickets);pthread_mutex_unlock(&mutex);}else{pthread_mutex_unlock(&mutex);break;}}printf("%s quit!\n", name);pthread_exit((void*)0);
}
int main()
{pthread_mutex_init(&mutex, NULL);pthread_t t1, t2, t3, t4;pthread_create(&t1, NULL, TicketGrabbing, "thread 1");pthread_create(&t2, NULL, TicketGrabbing, "thread 2");pthread_create(&t3, NULL, TicketGrabbing, "thread 3");pthread_create(&t4, NULL, TicketGrabbing, "thread 4");pthread_join(t1, NULL);pthread_join(t2, NULL);pthread_join(t3, NULL);pthread_join(t4, NULL);pthread_mutex_destroy(&mutex);return 0;
}

互斥锁的原理

OS提供了直接交换  寄存器和内存值 的一条汇编指令,mutex在内存中初始为1,第一个线程进来申请锁,先把寄存器的值置为0,然后把mutex与寄存器的内容交换,再判断寄存器的值是否是大于0。即使在这个过程中出现了线程切换也没有关系,寄存器的值还是会跟着线程上下文一起被带走。因此它可以保证申请锁是安全的。

什么叫做阻塞?

  • 站在操作系统的角度,进程等待某种资源,就是将当前进程的task_struct放入对应的等待队列,这种情况可以称之为当前进程被挂起等待了。
  • 站在用户角度,当进程等待某种资源时,用户看到的就是自己的进程卡住不动了,我们一般称之为应用阻塞了。
  • 这里所说的资源可以是硬件资源也可以是软件资源,锁本质就是一种软件资源,当我们申请锁时,锁当前可能并没有就绪,可能正在被其他线程所占用,此时当其他线程再来申请锁时,就会被放到这个锁的资源等待队列当中。

线程同步

同步: 在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,这就叫做同步。
竞态条件: 因为时序问题,而导致程序异常,我们称之为竞态条件。

单纯的加锁是会存在某些问题的,如果个别线程的竞争力特别强,每次都能够申请到锁,但申请到锁之后什么也不做,所以在我们看来这个线程就一直在申请锁和释放锁,这就可能导致其他线程长时间竞争不到锁,引起饥饿问题。现在我们增加一个规则,当一个线程释放锁后,这个线程不能立马再次申请锁,该线程必须排到这个锁的资源等待队列的最后。

实现方法就是条件变量:

条件变量是利用线程间共享的全局变量进行同步的一种机制,条件变量是用来描述某种资源是否就绪的一种数据化描述。

它会使得 线程 去某一个条件变量下进行等待,直到pthread_cond_signal,它被唤醒,再次查看条件是否满足,条件变量一定要与互斥量一起使用。

等待条件变量的代码

pthread_mutex_lock(&mutex);
while (条件为假)pthread_cond_wait(&cond, &mutex);
修改条件
pthread_mutex_unlock(&mutex);

唤醒等待线程的代码

pthread_mutex_lock(&mutex);
设置条件为真
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);

基于阻塞队列的生产消费模型

生产者消费者模型是多线程同步与互斥的一个经典场景,其特点如下:

  • 三种关系: 生产者和生产者(互斥关系)、消费者和消费者(互斥关系)、生产者和消费者(互斥关系、同步关系)。
  • 两种角色: 生产者和消费者。(通常由进程或线程承担)
  • 一个交易场所: 通常指的是内存中的一段缓冲区,可以是某个容器,vector或者queue。

我们用代码编写生产者消费者模型的时候,本质就是对这三个特点进行维护。

模拟实现基于阻塞队列的生产消费模型

#include <iostream>
#include <pthread.h>
#include <queue>
#include <unistd.h>#define NUM 5template<class T>
class BlockQueue
{
private:bool IsFull(){return _q.size() == _cap;}bool IsEmpty(){return _q.empty();}
public:BlockQueue(int cap = NUM): _cap(cap){pthread_mutex_init(&_mutex, nullptr);pthread_cond_init(&_full, nullptr);pthread_cond_init(&_empty, nullptr);}~BlockQueue(){pthread_mutex_destroy(&_mutex);pthread_cond_destroy(&_full);pthread_cond_destroy(&_empty);}//向阻塞队列插入数据(生产者调用)void Push(const T& data){pthread_mutex_lock(&_mutex);while (IsFull()){//不能进行生产,直到阻塞队列可以容纳新的数据pthread_cond_wait(&_full, &_mutex);}_q.push(data);pthread_mutex_unlock(&_mutex);pthread_cond_signal(&_empty); //唤醒在empty条件变量下等待的消费者线程}//从阻塞队列获取数据(消费者调用)void Pop(T& data){pthread_mutex_lock(&_mutex);while (IsEmpty()){//不能进行消费,直到阻塞队列有新的数据pthread_cond_wait(&_empty, &_mutex);}data = _q.front();_q.pop();pthread_mutex_unlock(&_mutex);pthread_cond_signal(&_full); //唤醒在full条件变量下等待的生产者线程}
private:std::queue<T> _q; //阻塞队列int _cap; //阻塞队列最大容器数据个数pthread_mutex_t _mutex;pthread_cond_t _full;pthread_cond_t _empty;
};

然后在主函数中创建生产者和消费者线程,并控制生产和消费的速度。这个模型中生产和消费不能同时进行,因为push和pop涉及对quque中size的修改,而它是非线程安全的。

信号量

我们可以将这块临界资源再分割为多个区域,当多个执行流需要访问临界资源时,如果这些执行流访问的是临界资源的不同区域,那么我们可以让这些执行流同时访问临界资源的不同区域,此时不会出现数据不一致等问题。

int sem_init(sem_t *sem, int pshared, unsigned int value);

信号量的PV操作:

  • P操作:我们将申请信号量称为P操作,申请信号量的本质就是申请获得临界资源中某块资源的使用权限,当申请成功时临界资源中资源的数目应该减一,因此P操作的本质就是让计数器减一。
  • V操作:我们将释放信号量称为V操作,释放信号量的本质就是归还临界资源中某块资源的使用权限,当释放成功时临界资源中资源的数目就应该加一,因此V操作的本质就是让计数器加一。
#include <iostream>
#include <string>
#include <unistd.h>
#include <pthread.h>
#include <semaphore.h>class Sem{
public:Sem(int num){sem_init(&_sem, 0, num);}~Sem(){sem_destroy(&_sem);}void P(){sem_wait(&_sem); // sem_wait就是P操作}void V(){sem_post(&_sem);}
private:sem_t _sem;
};Sem sem(1); //二元信号量
int tickets = 2000;
void* TicketGrabbing(void* arg)
{std::string name = (char*)arg;while (true){sem.P();if (tickets > 0){usleep(1000);std::cout << name << " get a ticket, tickets left: " << --tickets << std::endl;sem.V();}else{sem.V();break;}}std::cout << name << " quit..." << std::endl;pthread_exit((void*)0);
}int main()
{pthread_t tid1, tid2, tid3, tid4;pthread_create(&tid1, nullptr, TicketGrabbing, (void*)"thread 1");pthread_create(&tid2, nullptr, TicketGrabbing, (void*)"thread 2");pthread_create(&tid3, nullptr, TicketGrabbing, (void*)"thread 3");pthread_create(&tid4, nullptr, TicketGrabbing, (void*)"thread 4");pthread_join(tid1, nullptr);pthread_join(tid2, nullptr);pthread_join(tid3, nullptr);pthread_join(tid4, nullptr);return 0;
}

基于环形队列的生产消费模型

blank_sem和data_sem的初始值设置

现在我们用信号量来描述环形队列当中的空间资源(blank_sem)和数据资源(data_sem),在我们初始信号量时给它们设置的初始值是不同的:

  • blank_sem的初始值我们应该设置为环形队列的容量,因为刚开始时环形队列当中全是空间。
  • data_sem的初始值我们应该设置为0,因为刚开始时环形队列当中没有数据
#pragma once#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <semaphore.h>
#include <vector>#define NUM 8template<class T>
class RingQueue
{
private://P操作void P(sem_t& s){sem_wait(&s);}//V操作void V(sem_t& s){sem_post(&s);}
public:RingQueue(int cap = NUM): _cap(cap), _p_pos(0), _c_pos(0){_q.resize(_cap);sem_init(&_blank_sem, 0, _cap); //blank_sem初始值设置为环形队列的容量sem_init(&_data_sem, 0, 0); //data_sem初始值设置为0}~RingQueue(){sem_destroy(&_blank_sem);sem_destroy(&_data_sem);}//向环形队列插入数据(生产者调用)void Push(const T& data){P(_blank_sem); //生产者关注空间资源_q[_p_pos] = data;V(_data_sem); //生产//更新下一次生产的位置_p_pos++;_p_pos %= _cap;}//从环形队列获取数据(消费者调用)void Pop(T& data){P(_data_sem); //消费者关注数据资源data = _q[_c_pos];V(_blank_sem);//更新下一次消费的位置_c_pos++;_c_pos %= _cap;}
private:std::vector<T> _q; //环形队列int _cap; //环形队列的容量上限int _p_pos; //生产位置int _c_pos; //消费位置sem_t _blank_sem; //描述空间资源sem_t _data_sem; //描述数据资源
};

因为只有当生产者和消费者指向同一个位置并访问时,才会导致数据不一致的问题,而此时生产者和消费者在对环形队列进行写入或读取数据时,只有两种情况会指向同一个位置:

  • 环形队列为空时。
  • 环形队列为满时。

但是在这两种情况下,生产者和消费者不会同时对环形队列进行访问:

  • 当环形队列为空的时,消费者一定不能进行消费,因为此时数据资源为0。
  • 当环形队列为满的时,生产者一定不能进行生产,因为此时空间资源为0。

也就是说,当环形队列为空和满时,我们已经通过信号量保证了生产者和消费者的串行化过程。而除了这两种情况之外,生产者和消费者指向的都不是同一个位置,因此该环形队列当中不可能会出现数据不一致的问题。并且大部分情况下生产者和消费者指向并不是同一个位置,因此大部分情况下该环形队列可以让生产者和消费者并发的执行。

Linux线程池

线程池是一种线程使用模式。

线程过多会带来调度开销,进而影响缓存局部和整体性能,而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。

线程池的优点

  • 线程池避免了在处理短时间任务时创建与销毁线程的代价。
  • 线程池不仅能够保证内核充分利用,还能防止过分调度。

线程池中的维护一个任务队列,初始化 启动num个线程,它不断检测任务队列是否为空,不为空则从任务队列取出任务来执行。而我们使用时则只需要往任务队列push任务T即可。

#pragma once#include <iostream>
#include <unistd.h>
#include <queue>
#include <pthread.h>#define NUM 5//线程池
template<class T>
class ThreadPool
{
private:bool IsEmpty(){return _task_queue.size() == 0;}void LockQueue(){pthread_mutex_lock(&_mutex);}void UnLockQueue(){pthread_mutex_unlock(&_mutex);}void Wait(){pthread_cond_wait(&_cond, &_mutex);}void WakeUp(){pthread_cond_signal(&_cond);}
public:ThreadPool(int num = NUM): _thread_num(num){pthread_mutex_init(&_mutex, nullptr);pthread_cond_init(&_cond, nullptr);}~ThreadPool(){pthread_mutex_destroy(&_mutex);pthread_cond_destroy(&_cond);}//线程池中线程的执行例程static void* Routine(void* arg){pthread_detach(pthread_self());ThreadPool* self = (ThreadPool*)arg;//不断从任务队列获取任务进行处理while (true){self->LockQueue();while (self->IsEmpty()){self->Wait();}T task;self->Pop(task);self->UnLockQueue();task.Run(); //处理任务}}void ThreadPoolInit(){pthread_t tid;for (int i = 0; i < _thread_num; i++){pthread_create(&tid, nullptr, Routine, this); //注意参数传入this指针}}//往任务队列塞任务(主线程调用)void Push(const T& task){LockQueue();_task_queue.push(task);UnLockQueue();WakeUp();}//从任务队列获取任务(线程池中的线程调用)void Pop(T& task){task = _task_queue.front();_task_queue.pop();}
private:std::queue<T> _task_queue; //任务队列int _thread_num; //线程池中线程的数量pthread_mutex_t _mutex;pthread_cond_t _cond;
};

pthread_cond_broadcast函数的作用是唤醒条件变量下的所有线程,而外部可能只Push了一个任务,我们却把全部在等待的线程都唤醒了,此时这些线程就都会去任务队列获取任务,但最终只有一个线程能得到任务。一瞬间唤醒大量的线程可能会导致系统震荡,这叫做惊群效应。因此在唤醒线程时最好使用pthread_cond_signal函数唤醒正在等待的一个线程即可。

相关文章:

  • 从M个元素中查找最小的N个元素时,使用大顶堆的效率比使用小顶堆更高,为什么?
  • 川翔云电脑32G大显存集群机器上线!
  • Linux内核之文件驱动随笔
  • Windows远程注入的一些问题
  • 从 0 到 1 打通 AI 工作流:Dify+Zapier 实现工具自动化调用实战
  • 25.4.22学习总结
  • Linux——基于socket编程实现简单的Tcp通信
  • 如何在 Java 中从 PDF 文件中删除页面(教程)
  • 删除不了jar包-maven clean package失败
  • 10.建造者模式:思考与解读
  • C++学习之游戏服务器开发十二nginx和http
  • Linux:简单自定义shell
  • 界面控件DevExpress WPF v25.1预览 - 支持Windows 11系统强调色
  • 【图像识别改名】如何批量识别多个图片的区域内容给图片改名,批量图片区域文字识别改名,基于WPF和腾讯OCR的实现方案和步骤
  • PLC互连全攻略:Profinet和EthernetIP实操演示
  • 极狐GitLab 项目功能和权限解读
  • GMS认证之 CTS Verifier认证新变化
  • 【前端】【业务逻辑】【面试】JSONP处理跨域原理与封装
  • Python 设计模式:回调模式
  • WebGis与WebGL是什么,两者之间的关系?
  • 五一节,和人民照相馆一起找回“拍照”的仪式感
  • 研讨会|中国古代石刻与历史研究的多重图景
  • 全国人大常委会调研组在宁波调研,张庆伟带队钟山易炼红参加
  • 万斯偕印裔妻子访问印度,4天行程能否推进美印贸易谈判?
  • 大学2025丨浙大哲学院院长王俊:文科的价值不在于直接创造GDP
  • 世界读书日丨这50本书,商务印书馆推荐给教师