Linux操作系统编程基础

Linux系统开发基础

Linux系统开发概念

  • 内核

    • 操作系统的核心部分,提供硬件抽象、文件系统控制、多任务支持等功能
    • Linux操作系统的核心是Linux kernel
    • 截止2019.6.25,最新版本是V5.1.15
  • 内核模块

    • LKM(Loadable Kernel Module),对Linux内核的扩展
    • 可动态加载/卸载
    • Linux设备驱动的常用开发方式
    • 可单独编译,但必须作为内核的一部分运行
      • 使用内核的Makefile进行编译
      • 使用insmod/modprobe命令加载运行
      • 使用rmmod命令卸载
  • 进程

    • Linux内核的最小资源分配单位
    • 正在执行中的应用程序(程序运行实例)
    • 每个进程在内核对应一个PCB数据结构
      • Q:一个应用程序是否对应一个进程?
        • 如果从概念上讲:程序是静态的,是文件. 进程是动态的,是操作系统进行资源分配的概念. 那么一个程序文件可以开启多个实例
      • Q:多个进程是否运行在同一个内存地址空间?
        • NO
  • 线程

    • Linux内核的最小调度单位(最小执行单位)
    • 又称轻量级进程,每个线程也拥有一个PCB数据结构
      • struct task_struct结构体
    • 多个线程可共享同一个进程地址空间内的所有资源
    • 内核线程 VS. 用户线程
    • kthread vs. pthread
  • 进程间通信

    • UNIX IPC
      • PIPE: 管道
      • FIFO: 有名管道
      • Signal: 信号
    • System V IPC
      • Semaphore: 信号量
      • Message Queue: 消息队列
      • Shared Memory: 共享内存
    • BSD Socket
      • UNIX domain socket: AF_UNIX
      • Network socket: AF_INET, TCP/UDP
        • target IP: 127.0.0.1
        • target IP: remote IP

在这里插入图片描述

深刻理解Linux进程间通信

Linux IPC总结(全)

进程地址空间

地址空间:内核空间 VS. 用户空间

  • 内核空间
    • 存放内核运行时的代码和数据
    • 进程不能直接访问内核空间中的数据
    • 当程序运行在内核空间时,处于内核态
      • Q:内核态程序运行时有哪些特性?
        • 内核态程序运行在内核空间上,可以执行任何处理器指令(包括特权指令)
  • 用户空间
    • 存放应用程序运行时的代码和数据
    • 每个进程拥有自己独立的用户空间
    • 进程之间不能相互访问对方的用户空间
      • Q:进程之间如何交换数据?
      • IPC(Inter-Process Communication,进程间通信)
    • 当程序运行在用户空间时,处于用户态
      • Q:用户态程序运行时有哪些特性?

程序运行:内核态 VS. 用户态

  • 可访问的空间不同
    • 内核态:只能访问内核空间
    • 用户态:只能访问用户空间
  • 可执行的操作(处理器指令)不同
    • 内核态:可以执行任何处理器指令(包括特权指令)。
    • 用户态:不能执行硬件相关指令和处理器特权指令。
  • 进程是否只能运行在用户态?
    • 进程本身的代码都运行在用户态
    • 当进程使用内核提供的服务时会变为内核态
      • Q:为什么需要进入内核态执行?
        • 进程在执行硬件相关操作,访问操作系统内核提供的服务时,需要进入内核态执行。
  • 进程如何进入到内核态?
    • 系统调用
      • 什么是系统调用?
        进程使用系统调用从用户空间陷入到内核空间运行。当进程需要使用内核服务来操作硬件时,就需要通过系统调用陷入的方式(因为用户态不能执行硬件操作)。

系统调用

  • 为什么需要系统调用?
    • 在Linux中,系统调用是用户空间访问内核的唯一手段;除异常和陷入外,他们是内核唯一的合法入口。
    • 进程通过系统调用来使用内核提供的一些服务(执行内核函数)
      • 进程希望执行硬件操作,例如写文件(写磁盘)
      • 用户态不能执行硬件相关指令
  • 进程上下文
    • 进程执行环境,和特定进程有关:陷入内核态之前保存,回到用户态之后恢复
    • 系统调用过程可以阻塞
  • 系统调用通常被封装为库函数
    • 尽量使用库函数而非系统调用
    • 并非所有库函数都会调用到系统调用(比如math相关的库函数)
      在这里插入图片描述

虚拟地址VS.物理地址

为什么要使用虚拟地址?

用户进程,以及大部分的内核线程,都只能看到虚拟地址。内核中的地址映射模块实现了虚拟地址到物理地址之间的映射和转换。

通过虚拟地址访问内存,有如下优势:

  • 程序可以使用一系列相邻的虚拟地址来访问物理内存中不相邻的大内存缓冲区。

  • 程序可以使用一系列虚拟地址来访问大于可用物理内存的内存缓冲区。当物理内存的供应量变小时,内存管理器会将物理内存页(通常大小为4KB)保存到磁盘文件。数据或代码页会根据需要在物理内存与磁盘之间移动。

  • 不同进程使用的虚拟地址彼此隔离。一个进程中的代码无法更改正在由另一进程或操作系统使用的物理内存。一旦有访问非法地址,地址映射模块就会立即报错。

Linux系统中为什么不适用物理地址而是使用虚拟地址

1、物理地址

物理地址实际上就是硬件设备上实际的存储设备,内存硬件地址,与处理器的地址总线相对应,数据真实访问的位置,一般程序都无法看到物理内存地址。

2、虚拟地址

虚拟地址是Linux内核虚拟出来的地址,经由MMU内存管理单元映射到实际的物理地址。MMU是实际的管理内存的硬件。程序访问内存硬件使用的虚拟内存地址,由操作系统抽象出来的虚拟内存可以使得程序访问比真实物理内存大得多的内存范围,通过内核的地址映射功能(借助硬件MMU)实现。

3、直接使用物理地址

如果直接使用物理地址的话:

(1)安全风险

每个进程都可以访问0-4G的任意的内存空间,这也就意味着任意一个进程都能够去读写系统相关内存区域,如果是一个木马病毒,那么他就能随意的修改内存空间,让设备直接瘫痪

(2)地址不确定

众所周知,编译完成后的程序是存放在硬盘上的,当运行的时候,需要将程序搬到内存当中去运行,如果直接使用物理地址的话,我们无法确定内存现在使用到哪里了,也就是说拷贝的实际内存地址每一次运行都是不确定的,比如:第一次执行a.out时候,内存当中一个进程都没有运行,所以搬移到内存地址是0x00000000,但是第二次的时候,内存已经有10个进程在运行了,那执行a.out的时候,内存地址就不一定了

(3)效率低下

如果直接使用物理内存的话,一个进程就是作为一个整体(内存块)操作的,如果出现物理内存不够用的时候,我们一般的办法是将不常用的进程拷贝到磁盘的交换分区中,好腾出内存,但是如果是物理地址的话,就需要将整个进程一起拷走,这样,在内存和磁盘之间拷贝时间太长,效率较低。

4、虚拟地址实现

​ 虚拟地址实际上就相当于在物理地址和进程间引入一个第三者,一般实现方法有两种:分段映射和分页映射。

在这里插入图片描述
分段映射能够解决安全隐患、地址不确定问题,但是对于效率问题仍然没有很好的解决。因此引出了新的方法:分页方式。分页的方式实际上就是讲内存以4KB为单位分页(一页4KB),然后在Linux内核中提供页项目表、页表,一个大小占多个页的进程,在运行的时候,并不是所有的也都在运行,这时候将运行的页拷贝到内存,这样就缓解了效率的问题。

在这里插入图片描述
5、进程虚拟4G内存空间

​ 对于硬件来说只有4G的实际物理地址,每个程序在编译的时候,都在链接阶段,将elf程序虚拟地址设置在0x8048000开始,解决程序运行地址不固定的问题.

在这里插入图片描述在这里插入图片描述
  • 32位平台的进程地址空间

  • https://www.hackerearth.com/zh/practice/notes/memory-layout-of-c-program/

  • https://www.geeksforgeeks.org/memory-layout-of-c-program/
    在这里插入图片描述
    进程虚拟地址空间包含:代码段、数据段、BSS段、堆、栈、环境变量、内存映射区间(如共享库加载、mmap等)、内核空间等。

  • 64位平台的进程地址空间
    在这里插入图片描述
    在这里插入图片描述

Linux应用开发环境

Linux应用程序编译工具

  • GCC:GUN Compiler Collection

    • GNU开发的编译器套装,拥有一系列强大的编译工具
    • 最初的GCC:GNU C Compiler
    • 扩展后,不仅可以编译C语言,还可以编译C++、Objective-C、Java、Pascal等等
    • 除了编译,还有很多强大工具,例如objdump、nm、gcov等等
  • gcc包揽整个编译过程

    • 预处理
      • -E选项:该选项只对文件进行预处理,预处理的输出结果被送到标准输出。
    • 编译
      • -S选项:使用该选项会生成一个后缀名为.s的汇编语言文件,但是同样不会生成可执行程序。
    • 汇编
      • -c选项:该选项告诉GCC编译器仅把源程序编译为目标代码而不做链接工作
    • 链接
      在这里插入图片描述
  • GNU Make

  • GNU Make简介

    • make能做什么?
      • 自动化编译
      • 源代码工程的快速构建
      • 构建规则:Makefile
        • 描述了整个工程所有文件的编译顺序、编译规则
  • Makefile包含什么?

    • 变量
    • 函数
    • 规则
  • GNU Make工作原理

    • make命令读取Makefile
    • Makefile告诉make怎么构建工程(怎么编译和链接程序):规则
    • make读取Makefile后,会建立一个编译过程的描述数据库
      • 记录文件之间的相互关系,以及他们的关系描述
      • make会一级一级地寻找依赖,直到编译出第一个目标文件为止
      • 如果某个依赖不存在,且找不到构建规则,则构建终止
      • 不被依赖的目标,不会自动构建
    • make通过比较规则文件的目标和依赖的最后修改时间,来决定:
      • 哪些文件需要更新?
      • 哪些文件可以维持不变?
      • Q:这样有什么好处?
    • 对于需要更新的文件,通过数据库中记录的命令进行重建
      • Makefile规则中的命令部分
    • 对于不需要更新的文件,则忽略这些文件的规则
  • Makefile

    • Makefile总是由若干规则组成
      • 规则由目标、依赖、构建命令三部分组成
        • 目标通常是一个文件(要构建的目标文件)
        • 伪目标:不是真正的文件
        • 多个依赖之间以空格隔开
        • 构建命令由TAB键打头,一条命令占据一行
          • 每一个命令行都是一个独立的子shell
      • 显式规则、隐含规则、通配符、模式匹配
      • 变量与函数
      • 可以包含注释,以“#”打头
    • 构建规则,总是从Makefile第一个规则开始
      • 构建的最后目标文件,总是作为第一个规则的目标
      • 如果有伪目标,也可以作为伪目标的依赖
      • make命令后指定目标,可以从特定目标开始
  • Makefile

    • 变量赋值方法
      • var := value # 在变量定义时展开
      • var = value # 在变量使用时展开
      • var ?= value # 在变量为空时赋值
      • var += value # 给变量追加值(在原值末尾追加)
    • 常用内置变量
      • $@ # 当前规则的目标
      • $< # 第一个依赖
      • $^ # 所有依赖
      • $? # 所有比目标更新的依赖
      • $* # 模式匹配规则中,匹配模式的部分
      • $(MAKE) # 指代make命令自身
    • 常用内置函数
      • 执行shell命令:$(shell cmdline)
      • 通配符展开:$(wildcard *.c)
      • 固定字符串替换:$(subst srcstr,deststr,text)
      • 模式匹配替换:$(patsubst %.c,%.o,$(wildcard *.c))
      • 替换后缀名:​$(SRC:%.c=%.o)
    • 引用其他Makefile
      • include foo.mak
    • 递归Makefile
      • make -C subdir
        在这里插入图片描述

Linux应用程序常用调试方法

  • 各种调试工具的使用

  • 添加调试日志

    • 可以设定日志等级
    • 根据日志分析进行代码走读
  • 代码审查

    • 最常用、最有效的调试手段
    • 对照编码规范与代码审查表
    • 上机调试前的必备工作
    • 指针运算要特别小心
    • 注意成对操作的匹配
    • 不要急着编译运行
      • 准备充分了吗?
      • 不要做浮躁的人
  • Linux系统常用调试工具

    • strace:跟踪系统调用的执行情况
    • mtrace:跟踪内存泄露问题
    • gdb:强大的应用程序调试工具
      • 设置断点
      • 单步跟踪
      • 查看内存与寄存器
    • 学会结合系统命令进行程序跟踪调试
      • top
      • ps
    • 命令不会用?没有调试思路?
      • 学会使用搜索引擎
      • 学会使用man命令
      • 学会思考

Linux系统编程入门

进程控制与进程间通信

进程控制

  • 进程
    • PID:Process ID,进程ID
      • 进程在系统中的唯一标识
    • 什么是进程?
      • a process is an instance of a program in execution
      • 进程就是处于执行期的程序(目标代码存放在某种存储介质上)。但进程并不仅仅局限于一段可执行程序代码(Unix称其为代码段,text section)。通常进程还要包含其他资源,像打开的文件,挂起的信号,内核内部数据,处理器状态,一个或多个具有内存映射的内存地址空间及一个或多个执行线程(thread of execution),当然还包括用来存放全局变量的数据段等。实际上,进程就是正在执行的程序代码的实时结果。
  • 空间布局
    • 每个进程拥有独立的用户空间

    • 每个进程拥有两个栈

      • 用户栈:位于进程的用户空间

      • 内核栈:位于系统的内核空间

      • 进程的堆栈

        内核在创建进程的时候,在创建task_struct的同时,会为进程创建相应的堆栈。每个进程会有两个栈,一个用户栈,存在于用户空间,一个内核栈,存在于内核空间。
        当进程在用户空间运行时,cpu堆栈指针寄存器里面的内容是用户堆栈地址,使用用户栈;
        当进程在内核空间运行时,cpu堆栈指针寄存器里面的内容是内核栈空间地址,使用内核栈。

      • 进程用户栈和内核栈的切换

        当进程因为中断或者系统调用而陷入内核态之行时,进程所使用的堆栈也要从用户栈转到内核栈。

        进程陷入内核态后,先把用户态堆栈的地址保存在内核栈之中,然后设置堆栈指针寄存器的内容为内核栈的地址,这样就完成了用户栈向内核栈的转换;当进程从内核态恢复到用户态之行时,在内核态之行的最后将保存在内核栈里面的用户栈的地址恢复到堆栈指针寄存器即可。这样就实现了内核栈和用户栈的互转。

        那么,我们知道从内核转到用户态时用户栈的地址是在陷入内核的时候保存在内核栈里面的,但是在陷入内核的时候,我们是如何知道内核栈的地址的呢?

        关键在进程从用户态转到内核态的时候,进程的内核栈总是空的。这是因为,当进程在用户态运行时,使用的是用户栈,当进程陷入到内核态时,内核栈保存进程在内核态运行的相关信息,但是一旦进程返回到用户态后,内核栈中保存的信息无效,会全部恢复,因此每次进程从用户态陷入内核的时候得到的内核栈都是空的。所以在进程陷入内核的时候,直接把内核栈的栈顶地址给堆栈指针寄存器就可以了。

    • 进程的地址空间由哪些部分组成?

      • 进程虚拟地址空间包含:代码段、数据段、BSS段、堆、栈、环境变量、内存映射区间(如共享库加载、mmap等)、内核空间等。
  • 启动与退出
    • 进程启动:fork() + execve()

      • 进程入口:main函数
      • 当进程调用一种exec函数时,该进程执行的程序完全替换为新程序,而新程序则从其main函数开始执行。因为调用exec并不创建新进程,所以前后的进程ID并未改变。exec只是用磁盘上的一个新程序替换了当前进程的正文段、数据段、堆段和栈段。
      • 在这里插入图片描述
    • 进程退出

      • 主动退出:exit() / main函数执行return语句
        +在这里插入图片描述
    • 被动退出:信号(SIGKILL、SIGTERM、SIGABRT、SIGBUS、SIGSEGV等等)

      • 某个信号出现时,可以告诉内核按下列三种方式之一进行处理,称之为信号的处理或与信号相关的动作:
        • 信号可以捕捉,重定向它的处理(有些信号如SIGKILL/SIGSTOP,是无法捕捉的)
        • 信号可以忽略,有两种信号绝不能被忽略,他们是SIGKILL和SIGSTOP,原因是他们向内核和超级用户提供了使进程终止或停止的可靠方法。
        • 执行系统默认动作,大多数信号的默认动作是终止该进程
      • 有8种方式使进程终止(termination),其中5种为正常终止,他们是:
        • main返回
        • 调用exit
        • 调用_exit_Exit
        • 最后一个线程从其启动例程返回
        • 从最后一个线程调用pthread_exit
      • 异常终止有三种方式,他们是
        • 调用abort
        • 接到一个信号
        • 最后一个线程对取消请求做出响应
    • shell命令运行的进程是怎么启动的?

      1. 获取命令行

      2. 解析命令行

      3. 建立一个子进程(fork)

      4. 替换子进程(execvp)

      5. 父进程等待子进程退出(wait)

    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
      #include <fcntl.h>
      #include <unistd.h>
      #include <ctype.h>
      #include <sys/wait.h>
       
      void do_exe(char* buf, char* argv[]) //加载程序
    {
          pid_t pid = fork();
       
          if(pid == 0)//子进程
          {
              execvp(buf, argv);
              perror("fork"); //执行到此,说明execvp未执行成功,fork失败
              exit(1);
          }
          wait(NULL);  //等待子进程死亡, 回收
       
      }
      //对命令进行解析
      void do_parse(char* buf)
      {
          char* argv[8] = {}; //将buf中的命令以‘ ’为分界存入指针数组中
          int argc = 0;
          int status = 0; //一个新的字符串
          for(int i =0; buf[i] != 0; ++i){
              if(status ==0 && !isspace(buf[i])){
                  argv[argc++] = buf +i;
                  status = 1;
              } else if(isspace(buf[i])){
                  status = 0;
                  buf[i] = 0;
              }
          }
          argv[argc] = NULL;
       
          do_exe(buf, argv);
      }
       
      int main(void)
      {
          //  char* argv[] = {"ls", "-lah", NULL};
          //  execvp("ls", argv);//替换地址空间,实则将原进程的代码段,数据段进行替换,并未创建新的进程出来。
       
          char buf[1024] = {};
          while(1)
          {
              printf("my shell#");
              memset(buf, 0x00, sizeof(buf));
       
              //[^\n]匹配除\n以外的所有字符,*用于抑制转换
              //scanf成功返回输入的项数
              while(scanf("%[^\n]%*c", buf) == 0)  { //为0表示只输入了换行
                  printf("my shell#");
                  while(getchar() != '\n');  //到获得了一个‘\n'
              }
              do_parse(buf);
          }
          return 0;
      }
+ 在Ubuntu终端bash上输入`ps -o pid,ppid,pgid,sid,tpgid,comm`可以得到:

  ```bash
  marvin@marvin-VirtualBox:~$ ps -o pid,ppid,pgid,sid,tpgid,comm
    PID  PPID  PGID   SID TPGID COMMAND
  11554 11552 11554 11554 16339 bash
  16339 11554 16339 11554 16339 ps
  ```

  可以发现,`ps`的父进程是`bash`,同时他们位于同一个会话组中。更详细的解释见《UNIX环境高级编程》9.9小节
  • 父进程
  • 创建进程的进程
    • PPID:Parent Process ID,父进程的PID
    • 父进程依靠fork()创建子进程
  • fork()的过程
    • 《Linux 内核设计与实现》
      +在这里插入图片描述

    • 在这里插入图片描述

    • 创建子进程的PCB并将子进程加入系统调度队列

    • 复制父进程的用户空间数据和系统对象等(父进程各种数据都拷贝给子进程)

      • 注意父进程打开的文件描述符
    • 复制父进程的当前线程

      • 除了当前线程,父进程的其他线程在子进程中都蒸发掉了
      • 注意父进程的上锁情况(避免子进程一运行就死锁)
    • 一个系统调用,两次返回

      • 父进程:返回子进程PID
      • 子进程:返回0

在这里插入图片描述

  • 进程资源
    • 内核资源:PCB(Process Control Block)
      • PID、PPID、打开文件列表、当前目录、当前终端信息等
      • 通过系统调用才能访问
      • 进程退出后,需要另外的进程回收
    • 用户空间资源:进程在用户空间运行产生的资源
      • 进程代码段、数据段、堆和栈、共享库空间等
      • 只有进程自身才能访问
      • 进程退出后,用户空间资源自动回收
        • 进程退出后,内核空间资源由父进程调用wait/waitpid回收,用户空间资源自动释放。
    • 进程属性控制
      • pid_t getpid():获取当前进程ID
      • pid_t getppid():获取父进程ID
      • pid_t getpgid(pid_t pid):获取指定进程所属的进程组ID
        • getpgid(0):获取当前进程的进程组ID,相当于getpgrp()
        • 为什么需要进程组
          • 那为啥Linux里要有进程组呢?其实,提供进程组就是为了方便对进程进行管理。假设要完成一个任务,需要同时并发100个进程。当用户处于某种原因要终止这个任务时,要是没有进程组,就需要手动的一个个去杀死这100个进程,并且必须要严格按照进程间父子兄弟关系顺序,否则会扰乱进程树。有了进程组,就可以将这100个进程设置为一个进程组,它们共有1个组号(pgrp),并且有选取一个进程作为组长(通常是“辈分”最高的那个,通常该进程的ID也就作为进程组的ID)。现在就可以通过杀死整个进程组,来关闭这100个进程,并且是严格有序的。
          • 进程必定属于一个进程组,也只能属于一个进程组。 一个进程组中可以包含多个进程。 进程组的生命周期从被创建开始,到其内所有进程终止或离开该组。
      • int setpgid(pid_t pid, pid_t pgid):创建新的进程组ID 或 移动进程到新的进程组(同一个会话内)
      • setpgid(0, 0):当前进程的进程组ID设置为当前进程ID,相当于setpgrp()
      • pid_t getsid(pid_t pid):获取指定进程的会话ID
        • 指定进程必须和当前进程在同一个会话内(pid为0表示获取当前进程的会话ID)
      • pid_t setsid():创建一个新会话,当前进程为新的进程组长,且没有控制终端
    • 进程状态跃迁过程

在这里插入图片描述
在这里插入图片描述

  • 进程状态

    • Linux进程的5种状态:运行、可中断的等待、不可中断的等待、停止、僵死

    • 运行:R(Running)

    • 等待:Waiting

      • 可中断的等待:S(Sleep,Interruptible Waiting)
      • 不可中断的等待:D(Uninterruptible Waiting)
    • 停止:T(Traced or Stopped)

    • 僵死:Z(Zombie)什么情况下会产生僵尸进程

      • 当用fork系统调用创建子进程后,子进程就独立于父进程开始自身的运行。当子进程先于父进程执行完毕后,尽管此时子进程已经不会再活动了,但是代表子进程的数据结构并不会立刻从进程表中销毁。因为子进程的退出代码需要保存起来以供父进程随后调用wait时使用,此时子进程变成了不在活动的进程,即僵死进程。僵死进程虽然不再活动,但是仍然占有一定的系统资源,直到父进程调用wait获得子进程的终止信息后,才最终予以释放。
      //filename: zombie.c
      #include<stdio.h>
      #include <stdlib.h>
      #include <sys/types.h>
      #include <unistd.h>
      
      int main()
      {
              pid_t child_pid;
              /*创建子进程*/
              child_pid = fork();
              if(child_pid >0){
                      sleep(60);
                      /*父进程休眠60秒*/
              } else {
                      /*子进程退出*/
                      exit(0);
              }
              return 0;
      }
  • 在终端中运行程序后,打开另一个终端,执行命令
    $ ps -a -o pid,ppid,s=state,command

得到结果

        PID  PPID state COMMAND
      23553 22999 S     ./zombie
      23554 23553 Z     [zombie] <defunct>
      23609 23455 R     ps -a -o pid,ppid,s=state,command

可以看到此时子进程处于僵死状态,state显示为Z。这是因为父进程休眠60秒,在休眠期间子进程得到执行并立即执行exit,先于父进程退出,此时父进程还没有对子进程进行善后,因此已经退出的子进程仍然占用少量的系统资源。

为了避免僵死进程长期存在,父进程希望知道子进程何时结束运行。可以通过让父进程调用wait或waitpid暂时停止执行,等待子进程终止运行后,再继续执行父进程。

  • 特殊进程

    • idle进程:0号进程(swapper),内核初始化、CPU空闲指令、调度切换
    • init进程:1号进程,由内核创建,所有用户进程的祖先
    • 僵尸进程:父进程未回收PCB;
      • 僵尸进程有没有什么危害?
        • 僵尸进程会占用系统资源,如果很多,则会严重影响服务器的性能;
      • 如何防止僵尸进程
        • 让僵尸进程成为孤儿进程,由init进程回收;(手动杀死父进程)
        • 调用fork()两次;
        • 捕捉SIGCHLD信号,并在信号处理函数中调用wait函数;
        • 让僵尸进程的父进程来回收,父进程每隔一段时间来查询子进程是否结束并回收,调用wait()或者waitpid(),通知内核释放僵尸进程;
    • 孤儿进程:被init进程接管
      • 一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。子进程死亡需要父进程来处理,那么意味着正常的进程应该是子进程先于父进程死亡。当父进程先于子进程死亡时,子进程死亡时没父进程处理,这个死亡的子进程就是孤儿进程。但孤儿进程与僵尸进程不同的是,由于父进程已经死亡,系统会帮助父进程回收处理孤儿进程。所以孤儿进程实际上是不占用资源的,因为它终究是被系统回收了。不会像僵尸进程那样占用ID,损害运行系统。
      • 进程什么时候被init进程接管
        +
      • 孤儿进程不会占用系统资源,最终是由init进程托管,由init进程来释放;
    • 守护进程:服务的运行方式
  • 守护进程:精灵进程(Daemon Process)

    • 后台运行的特殊进程,脱离于终端,他们没有控制终端
      • 避免被终端信号打断
      • 执行过程任何信息都不会打印到终端上
      • 守护进程的输出,可以打印到系统syslog或自定义日志文件
    • 一般用于周期性执行任务或等待处理事件(系统服务)
    • 创建步骤:五步曲
      • fork一个子进程,父进程退出:成为孤儿,确保在后台运行
      • 在子进程中创建新会话(setsid):最重要的步骤,让进程脱离tty
      • 改变当前目录为根目录(或特定目录):避免文件系统卸载纠纷
      • 重置文件权限掩码:umask(0)(避免创建文件时权限受到影响)
      • 关闭文件描述符:包括0、1、2(或重定向到/dev/null)
    • 更简单的创建方式:daemon(0, 0)
      • 当前目录切换到根目录
      • STDIN、STDOUT、STDERR,都重定向到/dev/null
    • 守护进程的退出:一般使用kill(可以捕捉SIGTERM并重定向处理)
      • 这样做有什么好处
  • 应用程序到进程的蜕变

    • 应用程序启动:父进程fork() + 子进程execve()
    • 系统调用:execve()
    • C语言库函数:exec()家族
      • 在这里插入图片描述
      • execl(const char *path, const char *arg...)
        • path:程序绝对路径
        • arg...:可变参数列表,最后一个参数必须是(char *)NULL
      • execlp(const char *file, const char *arg...)
        • file:可以是相对路径,在$PATH中寻找
      • execle(const char *path, const char *arg..., char * const envp[])
        • envp[]:字符串数组形式的环境变量列表,元素形式“name=value”,最后一个元素总是NULL
      • execv(const char *path, char * const argv[])
        • argv[]:字符串数组形式的参数列表,最后一个元素总是NULL
      • execvp(const char *file, char * const argv[])
      • execvpe(const char *file, char * const argv[], char * const envp[])
        • a GNU extension, first appeared in glibc 2.11
      • execve(const char *path, char * const argv[], char * const envp[])
        • exec()家族的函数,最后都是调用这个系统调用函数
  • shell命令和脚本的运行

    • int system(const char *cmdline)
    • FILE *popen(const char *cmdline, const char *mode)
    • int pclose(FILE *stream)
  • 进程资源的回收

    • 用户空间资源回收:进程退出
      • exit()
        • int atexit(void (*func)(void))
        • int on_exit(void (*func)(int, void *), void *arg)
      • return from main()
    • 内核空间资源的回收:wait & waitpid
      • pid_t wait(int *status)
      • pid_t waitpid(pid_t pid, int *status, int options)
      • 如果父进程先于子进程退出,怎么回收子进程的PCB?
        • 子进程成为孤儿进程
    • SIGCHLD的妙用
      • 父进程可以注册SIGCHLD的处理函数,有子进程退出再调用wait/waitpid回收
      • 还可以让内核自己回收PCB:signal(SIGCHLD, SIG_IGN)
  • 进程异常退出

    • 收到导致进程终止的信号
    • 也会自动回收用户空间资源
    • 退出后会产生core dump
      • 进程终止前内存映像的快照:core文件
        • 包含内存数据、寄存器、堆栈等等
        • 可以用gdb进行进程终止时的现场上下文还原
      • core dump默认不产生
        • ulimit -c:0(不产生)/ size(core文件最大block数)/ unlimited(无限制,推荐值)
      • core文件的位置
        • cat /proc/sys/kernel/core_pattern
      • 不是所有信号都会产生core dump的
        • SIGQUIT / SIGILL / SIGTRAP / SIGABRT / SIGBUS / SIGSEGV / SIGFPE

进程间通信

  • 常用的进程间通信方法
    • 信号量
    • 消息队列
    • 共享内存
    • 管道
    • 命名管道
    • 信号
    • Socket
      • UNIX Domain
      • TCP/UDP
      • domain socket的性能优于TCP/UDP socket

在这里插入图片描述

System V IPC
  • 信号量:Semaphore

    • 进程同步机制
      • 计数器,不以传送数据为目的
    • 对共享资源的访问控制
      • 临界资源 & 临界区
      • 同步 & 互斥 & 资源计数
    • 操作系统上的经典IPC问题
      • 生产者与消费者
        • 生产者与消费者共享缓冲区,生产者把数据放入缓冲区,消费者从缓冲区取出数据
      • 读者写者问题
        • 一个公共文件,允许多个读者共同去读,一个写者在写
      • 哲学家进餐问题
        • 一个圆桌,5个哲学家围坐,5根筷子,每个哲学家拿到左右2根筷子才能吃饭。每个哲学家只做两件事情:吃饭,或者思考
      • 理发师理发问题
        • 理发店里有一位理发师、一把理发椅和n 把供等候理发的顾客坐的椅子。如果没有顾客,理发师便在理发椅上睡觉;当一个顾客到来时,他必须叫醒理发师;如果理发师正在理发时又有顾客来到,那么,如果有空椅子可坐,顾客就坐下来等待,否则就离开理发店
    • 信号量的基本操作
      • P操作:count > 0 => count--; count == 0 => waiting
      • V操作:someone waiting => wake up; nobody waiting => count++
  • 二元信号量

    • 互斥型信号量
    • 计数只在0和1之间变化
    • 常用于实现互斥锁
      • 初值一般为1,第一个P操作可以获取到锁
      • 如果初值为0,则第一个P操作立即锁住,需要先执行V操作才能解锁
  • System V Semaphore

    • 创建/获取:man semget

      在这里插入图片描述

    • 控制/初始化:man semctl

在这里插入图片描述
在这里插入图片描述

  • 操作(P/V):man semop
    在这里插入图片描述

  • 消息队列:Message Queue

    • 进程间的数据交换手段
    • 类似于FIFO,发送进程往队列尾部添加消息,接收进程从队列头部读取消息
    • 每个消息包含一个type(>0)、一个长度(>=0)、一个字节数组(消息内容)
      • 允许空消息的存在(即只有type,长度为0,没有字节数组)
      • type必须是long类型
    • 消息发送:
      • 严格按照FIFO次序发送,每次发送到队列尾部(不允许插队)
      • 消息队列满时阻塞(或立即返回队列满的错误)
    • 消息接收:
      • 可以按FIFO次序,从队列头部依次取出
        • 消息队列空时阻塞(或立即返回没有消息的错误)
      • 也可以按消息类型依次取出(比FIFO先进之处)
        • 消息队列中不存在所请求消息时阻塞(或立即返回没有消息的错误)
  • System V Message Queue

    • 创建/打开:man msgget
      在这里插入图片描述

    • 控制/初始化:man msgctl
      在这里插入图片描述
      在这里插入图片描述

    • 操作:发送/接收

      • 发送:man msgsnd

在这里插入图片描述
+ 自定义消息格式:type + data[0..N]
在这里插入图片描述
+ 接收:man msgrcv

在这里插入图片描述

  • 共享内存:Shared Memory

    • 允许两个或多个进程共享同一个存储区域
    • 最快的进程间数据交换方式
      • 两个进程访问同一个物理内存区域
      • 一个进程改变了共享内存区的内容,其他进程可以立即察觉
      • 进程之间内存区域共享,零拷贝
    • 没有任何同步与互斥机制
      • 需要使用信号量对共享内存区域的数据存取进行同步

在这里插入图片描述

  • System V Shared Memory

    • 创建/获取:man shmget

在这里插入图片描述

  • 控制/初始化:man shmctl

在这里插入图片描述
在这里插入图片描述

  • 操作:连接/分离

    • 连接:man shmat

在这里插入图片描述

  • shmaddr:推荐值 => NULL

    • 分离:man shmdt

      在这里插入图片描述

  • System V IPC用法总结

  • 三个步骤:

    • 创建/获取
    • 控制/初始化
    • 操作
      • 信号量:P/V
      • 消息队列:发送/接收
      • 共享内存:连接/分离
    • 使用完记得销毁:控制接口
  • IPC对象的键值:

    • IPC_PRIVATE:只能用于具有亲缘关系的进程之间的通信
    • ftok:生成唯一的键值,可用于任意进程之间的通信

在这里插入图片描述

  • System V IPC VS. POSIX IPC
    • POSIX IPC是对System V IPC的再封装
      • POSIX信号量
      • POSIX消息队列
      • POSIX共享内存
    • POSIX API拥有更简单的接口,更易于使用
      • 以名称作为唯一标识:“/somename”

在这里插入图片描述

  • System V IPC or POSIX IPC
    • System V IPC
      • 兼容性好,可移植性高(Linux / UNIX)
      • 处理更加灵活
      • 接口复杂度高
    • POSIX IPC
      • 标准化设计
      • 线程安全
      • 接口简单易用
      • 可以结合select/poll使用
      • 一些特殊场景的处理不如System V接口灵活
  • 结论:Linux平台开发,大多数场景使用POSIX API足矣
进程间通信 - UNIX IPC
  • UNIX PIPE
    • 无名管道
    • 连接两个进程的输入输出
    • 半双工:单向通信(无标识)
      • 面向字节流
      • 自行设计数据边界
      • 先入先出
    • 使用流程:
      • pipe():创建一对文件描述符
        • 分别用于读写
      • fork():创建两个进程
        • 一个用于读,一个用于写
        • 写端关闭读描述符
        • 读端关闭写描述符
      • 读写:
        • 当写端关闭时,读端读完所有数据后,read返回0(指示管道结束)
        • 当读端关闭时,写端写入动作触发SIGPIPE,write返回EPIPE
    • 缺点:
      • 基于fork()机制,只能用于具有亲缘关系的进程之间的通信
      • 只能用于两个进程之间的通信

在这里插入图片描述

  • UNIX FIFO

    • 命名管道(Named PIPE)
    • 半双工:单向通信(管道文件作为标识)
      • 可以实现任意进程之间的通信
      • 一个FIFO允许有多个写端和多个读端
    • 使用流程:
      • mkfifo():创建管道文件
        • 管道文件不存在时调用
      • open():打开管道文件,用于读写
        • Process 1:打开文件用于读
        • Process 2:打开文件用于写
        • 如果写端不存在,则读端阻塞到写端调用open()为止,反之亦然
      • read():从FIFO中读取数据
        • 若FIFO中无数据,将阻塞
        • 若有其他进程在读数据,将阻塞
      • write():往FIFO中写入数据
        • 当FIFO剩余空间不足以容纳写入数据时,将阻塞到写入完成为止
        • 如果待写入字节数大于PIPE_BUF(4KB),不保证写入的原子性
  • 信号(Unix Signal)

    • 软件层次对中断机制的一种模拟

    • 进程收到信号 VS. 处理器收到中断请求

      • 信号与中断的共同点

        (1)采用了相同的异步通信方式;
        (2)当检测出有信号或中断请求时,都暂停正在执行的程序而转去执行相应的处理程序;
        (3)都在处理完毕后返回到原来的断点;
        (4)对信号或中断都可进行屏蔽。

    • 异步请求

      • 随时打断

      • 注册处理函数

      • 信号与中断的区别:
        (1)中断有优先级,而信号没有优先级,所有的信号都是平等的;
        (2)信号处理程序是在用户态下运行的,而中断处理程序是在核心态下运行;
        (3)中断响应是及时的,而信号响应通常都有较大的时间延迟。

    • 什么是中断

      • 处理器运行过程中,因为某些外部请求或意外发生而被打断,转而运行新请求的处理程序,处理完毕后再返回原先被打断的地方继续运行。这种情况被
        称为中断
    • 信号来源:

      • 硬件来源:键入按键、硬件事件或硬件故障等
    • 软件来源:调用函数触发、软件条件触发、发生非法运算等

    • 信号的处理方式

      • 系统默认:每个信号都有一个系统定义的默认处理方法
      • 强制忽略:SIG_IGN
      • 捕捉处理:自定义处理函数
    • 常见信号含义

在这里插入图片描述

+ `kill -l`:列出系统支持的所有信号
+ 详细的信号含义:参考《UNIX环境高级编程》第10章 信号
+ <font color=red>两个信号不可捕捉或忽略:`SIGKILL`, `SIGSTOP`</font>
  • 信号的常见用途

    • 错误处理
  • 终止进程

    • 实现定时器
  • 进程间事件通知

    • 唯一的异步通信机制
    • 通常不能作为唯一的通信手段,需要辅以其他通信方式
    • 信号的触发
    • kill()/raise()
      • sigqueue()
    • alarm()/setitimer()
      • abort()
      • pause()/sleep()
  • 注册信号处理函数

    • signal:man signal

在这里插入图片描述

+ `sigaction:man sigaction`

在这里插入图片描述

  • 信号处理函数编写的注意事项:在信号处理函数中不得调用不可重入函数(参见《UNIX环境高级编程》表10-3)

  • 信号处理可重入

    • 信号处理函数是可重入的
      • 信号发生时,信号函数调用了系统函数如malloc。main主进程中也调用这个系统函数malloc。这种情况下会给main主进程的malloc调用带来不可预期的结果。因为malloc维护着一个内存分配的链表,信号函数也会对这个链表做修改。固malloc为不可重入函数。反之两者同时调用,而不会相互影响的则为可重入函数
    • 调用的函数也必须可重入;
    • 什么是可重入函数
      • 可以随时被中断,返回时继续执行不会出错的函数,称为可重入函数。
  • 如何编写可重入函数?
    1)不在函数内部使用静态或全局数据结构
    2)不返回静态或全局数据结构,所有数据都由调用者提供
    3)使用本地数据(局部变量),或通过制作全局数据的本地拷贝来使用全局数据
    4)不调用malloc/free函数
    5)不调用不可重入函数

进程间通信 - Socket
  • UNIX Domain Socket(AF_UNIX)

    • 主机内的进程间通信
    • 不需要打包/拆包、计算校验和、维护序号和应答等
    • 可靠通信(网络协议是不可靠的)
    • 地址格式:文件系统路径
  • TCP/UDP Socket(AF_INET)

    • 跨主机的进程间通信(网络间的通信)
    • 地址格式:IP地址 + TCP/UDP端口号
    • 特例:目标地址为127.0.0.1时,蜕变为主机内的进程间通信
  • socket使用过程

    • 类似于打电话的过程
    • socket的详细用法参见网络编程部分的介绍
  • 面向连接的socket通信过程举例(类比电话通信)

在这里插入图片描述

POSIX线程与定时器

POSIX线程 - pthread

  • pthread:用户线程

    • 用户进程的最小执行实体

    • 进程中的所有线程,共享进程资源

      • 内存地址空间(进程指令、数据等)
      • 打开的文件描述符
      • 信号处理程序
      • 当前工作目录
      • 用户ID和组ID
    • 线程之间有哪些资源不共享?

    • 拥有独立的运行栈

      • 独立分配CPU资源
      • 独立执行的指令序列
      • 进程内并发执行
    • 线程的优点:

      1. 提高程序的并发性,提高应用程序的响应效率,更有效地使用CPU
      2. 开销小,不需要重新分配内存和拷贝数据,占用较少的系统资源
      3. 共享进程地址空间,通信和共享数据更方便,程序运行和开发效率更高
    • 线程独有的资源:

      • 线程ID
      • 处理器现场和栈指针(寄存器集合)
      • 独立的栈空间(局部变量、返回值等)
      • errno变量
      • 信号掩码
      • 调度优先级
      • 线程专用存储(可选,可能不存在)
    • 并发与并行

      • CPU可以在多个线程之间切换运行,实现并发
      • 同一时刻,CPU的每个核上可以同时运行一个线程,实现并行
    • 主线程:进程入口运行的第一个线程,默认线程(入口:main函数)

在这里插入图片描述

  • pthread_t:线程ID(线程唯一标识)

    • Q:线程ID是进程内唯一还是系统内唯一?
      • 线程的ID在系统中绝对是唯一的https://www.jianshu.com/p/e423c4dccf17
      • POSIX thread ID可以在一个进程内唯一标识一个线程,但如果放到系统范围内的话就得用gettid了。http://bbs.chinaunix.net/thread-4097282-1-1.html
      • pthread_t:线程ID,只是进程内唯一,不同进程间还是可能相同的。
        pthread_t是进程内唯一,不同进程之间可能重复,但是作为轻量级进程,线程同样拥有一个PCB,有一个进程ID,称为TID,这个是系统内唯一的。
  • 创建线程:pthread_create

在这里插入图片描述

+ **线程创建后就开始运行吗?**
  • 终止线程:

    • 从线程入口函数返回

    • 被其他线程终止:pthread_cancel()

    • 线程主动退出:pthread_exit()

    • 如果从主线程main函数返回呢?

      • 线程不像进程,一个进程中的线程之间是没有父子之分的,都是平级关系。即线程都是一样的, 退出了一个不会影响另外一个。但是所谓的"主线程"main,其入口代码是类似这样的方式调用main的:exit(main(...))。main执行完之后, 会调用exit()exit() 会让整个进程终止,那所有线程自然都会退出。https://blog.csdn.net/fivedoumi/article/details/51863931
      • http://bbs.chinaunix.net/thread-1286290-1-1.html
    • 如果线程内调用exit()退出呢?

      • 如果进程中的任一线程调用了exit,Exit或者_exit,那么整个进程就会终止。https://blog.csdn.net/wyq393562305/article/details/24420679

      • 单个线程可以通过下列三种方式退出,在不终止整个进程的情况下停止它的控制流。

        (1)线程只是从启动例程中返回,返回值是线程的退出码。

        (2)线程可以被同一进程中的其他线程取消。

        (3)线程调用pthread_exit。

  • 线程属性:pthread_attr_t

  • 如果man pages中找不到pthread相关的接口,可能是未安装,可以尝试:sudo apt install manpages-posix

  • 线程的连接与分离:决定一个线程以什么方式结束自己

    • 连接状态:默认状态,线程结束后需要调用pthread_join()回收资源
    • 线程创建后,一般原线程会调用pthread_join()等待新线程结束后回收其资源
    • 分离状态:分离线程在运行结束后立即自动释放占用的资源
      • pthread_detach
        • 分离线程自己=>pthread_detach(pthread_self())
      • pthread_attr_setdetachstate
    • Q:pthread_join()等待的线程如果已经终止了呢?
    • Q:一个结束的线程如果未设置detach属性,也未执行join操作,会有什么后果?
  • 更多线程相关函数

在这里插入图片描述

  • 线程安全(Thread Safety)

    • 程序同时执行多个线程却不会“破坏”共享数据或者产生“竞争”条件的能力

    • 线程安全函数:被多个并发线程反复调用仍然可以保证结果正确性

    • 如何确保线程安全?

      • 使用局部变量
      • 避免使用全局变量
      • 共享不可变数据
      • 共享线程安全的可变数据
      • 怎么做到?
        • 使用可重入函数
        • 使用线程锁
    • 线程安全函数 VS. 可重入函数

      • 可重入函数一定是线程安全的

      • 线程安全函数不一定是可重入的

        • 如果一个函数的实现里面在不加锁的情况之下随意操作全局变量,那么它就是线程不安全的,因为该函数没有限制全局变量,没有让全局变量更加“适应”线程。那么什么是可重入函数呢?顾名思义,可重入就是一个执行绪没有离开这个函数的时候允许另一个执行绪进入该函数。线程安全强调的是该函数的实现操作,而可重入性则更多的强调该函数的调用规则,因此函数的可重入性约束要高于线程安全,可重入函数一定是线程安全的,可重入函数除了不能随意无锁操作全局或静态 变量外还不能返回它们,因为可重入性是调用相关的,一个函数的调用有几个方面:调用,实现,参数,返回值。

在这里插入图片描述

  为什么是线程安全的?因为glob是静态变量,是所有线程共享的,于是每次incer函数执行时都能读取正确的
  
  为什么不是可重入的?因为函数incer调用过程中如果其他函数修改了静态变量glob的值,那么函数中断后无法保证glob的值的正确性
  • 线程同步

    • 线程同步手段主要包括:互斥锁、读写锁、条件变量

      • 这些接口在POSIX标准中属于可选实现,如果在系统的man pages中找不到这些接口,可能尚未安装,可以补充安装一下:sudo apt install manpages-posix-dev
    • 互斥量

      • 线程互斥锁

      • 保护线程的临界区(共享资源)

      • 同一时刻只能有一个线程持有

        • Q:如果其他线程也尝试获取这把锁,会怎样?
        • Q:如果多个线程共同等待一把锁,谁先唤醒?
          • POSIX标准对此未定义,无法保证唤醒顺序。
      • 对比信号量

        • 支持进程同步、支持计数
        • Q:信号量是否可以用于进程内多线程之间的同步?
      • 使用接口

        • pthread_mutex_init
        • pthread_mutex_destroy
        • pthread_mutex_lock
        • pthread_mutex_unlock
        • pthread_mutex_trylock

在这里插入图片描述

+ 思考:如果有多个共享变量,该如何设计线程锁?

  + 每个共享变量独立一把锁?
    + 每个共享变量独立一把锁:锁太多,容易导致死锁,而且开销也大
  + 所有共享变量共用一把锁?
    + 所有共享变量共用一把锁:需要保护的临界区太大
  + 线程锁的设计,要讲究平衡,兼顾性能与程序复杂度,避免死锁
  • 读写锁

    • 读者写者问题:读共享,写独占
    • 类似于互斥锁,但是可以提高并行性与并发性
    • 读写锁的3种状态:
      • 读模式加锁
        • 其他读者可以进入临界区,但是写者会阻塞
      • 写模式加锁
        • 其他写者和读者都会阻塞
      • 不加锁
        • 读者可以加读锁(进入读模式加锁状态),写者可以加写锁(进入写模式加锁状态)
    • 使用接口:
      • pthread_rwlock_init / pthread_rwlock_destroy
      • pthread_rwlock_rdlock / pthread_rwlock_tryrdlock
      • pthread_rwlock_wrlock / pthread_rwlock_trywrlock
      • pthread_rwlock_unlock
  • 条件变量

    • 资源什么时候可用?轮询是一种方式,但是代价太高,浪费资源。条件变量是一种很好的替代方法

    • 事件等待与通知

    • 通常结合互斥锁一起使用

    • 互斥锁阻止共享数据的并发访问,条件变量通告共享数据的状态变化

  • Q:一定要使用条件变量吗?

  • 支持定时等待,事件发生或超时后都会返回

  • 支持一次唤醒一个等待线程,也支持批量唤醒所有等待线程(广播)

    • 使用接口:

      • 初始化:pthread_cond_init
      • 注销:pthread_cond_destroy
      • 等待:pthread_cond_wait
      • 定时等待:pthread_cond_timedwait
    • 通知(唤醒单个等待线程):pthread_cond_signal

    • 广播(唤醒所有等待线程):pthread_cond_broadcast

    • 典型的生产者和消费者问题的解法示例

在这里插入图片描述

  • 线程死锁

    • 线程相互等待,谁也无法往下执行

在这里插入图片描述

  • 产生死锁的必要条件

    • 互斥:竞争临界资源
    • 占有并等待:占有已得到的资源并等待新资源
    • 不可抢占:占有的资源不会被其他线程抢占
    • 环路等待:两个以上线程组成环路,每个线程都在等待另一个线程释放资源
  • 进程之间也会发生死锁

    • 经典死锁问题:哲学家进餐
  • 如果只有一个线程,会不会发生死锁?

    • 有可能
  • 如何避免死锁?

    • 只要破坏死锁的任意一个必要条件,死锁就无法发生
      • Q:如何破坏?
      • 对所有资源进行spooling(它是关于慢速字符设备如何与计算机主机交换信息的一种技术,通常称为“假脱机技术”)(Windows使用打印机的方式)
      • 初始时申请所有资源(难以预知,无法优化资源利用)
    • 将资源剥夺(难以实现,基本不会用它)
      • 对资源进行编号(难以找到合适的编号顺序)
  • Linux系统对死锁的处理:鸵鸟算法

    • 我们的系统对死锁不做处理(鸵鸟算法),应当在设计阶段避免死锁
    • 解决死锁的代价通常很大,复杂度高
      • 死锁应当在设计阶段避免
    • 鸵鸟算法是平衡性能与复杂度之后的无奈选择
      • 鸵鸟算法可以称之为不是办法的办法。在计算机科学中,鸵鸟算法是解决潜在问题的一种方法。假设的前提是,这样的问题出现的概率很低。比如,在操作系统中,为应对死锁问题,可以采用这样的一种办法。如果死锁很长时间才发生一次,而系统每周都会因硬件故障、编译器错误或操作系统错误而崩溃一次,那么大多数工程师不会以性能损失或者易用性损失的代价来消除死锁。大多数操作系统,包括UNIX,MINUX和windows,处理死锁问题的办法仅仅是忽略它。其假设前提是大多数用户宁可在极偶然的情况下发生死锁也不愿接受性能的损失。因为解决死锁的问题,通常代价很大。所以鸵鸟算法,是平衡性能和复杂性而选择的一种方法
  • 设计阶段如何避免死锁?

    • 避免交叉等待:让所有线程使用相同的顺序获取锁

      • 如果多个模块可能需要依次获取多个锁,可以将上锁和解锁封装成函数以保证上锁顺序
        foo_lock()
        {
            lock(lock1);
            lock(lock2);
            lock(lock3);
        }
      foo_unlock()
        {
            unlock(lock1);
            unlock(lock2);
            unlock(lock3);
        }
  • 所有线程在需要获得锁的时候调用相同的函数自然能够保证相同的上锁顺序

POSIX定时器

  • 定时器:定时产生特定事件或执行特定任务

    • 一次性任务 / 周期性任务
  • 创建定时器

    • timer_create

      • clockid:定时器基于哪种时钟创建
        • 绝对时间:日历时间(真实世界的时间)
        • 相对时间:系统时间、进程时间、线程时间
      • sevp:设置定时器到期时的通知方式和处理方式
        • 该结构体使用前要初始化(memset
        • 可以传NULL(到期产生默认信号)
        • 到期处理方式:
          • 不处理(SIGEV_NONE
          • 产生指定信号(SIGEV_SIGNAL
          • 创建一个处理线程(SIGEV_THREAD
      • timerid:保存创建后的定时器句柄
        • 进程内唯一

在这里插入图片描述
在这里插入图片描述

  • 启动定时器

    • timer_settime(timerid, flags, new_time, old_time)

      • 启动/停止/重置定时器

      • flags:

        • 0(默认,指示到期时间是与当前时间的间隔)
        • TIMER_ABSTIME(指示到期时间为绝对时间)
      • new_time:struct itimerspec

        • 指定定时器到期时间

在这里插入图片描述

+ old_time:

  + 可以是NULL
  + 否则用来保存旧的到期时间
  • 删除定时器

    • timer_delete(timerid)
  • 查看定时器到期时间

    • timer_gettime(timerid, curr_time)
    • 查看活动定时器到期剩余时间,或定时器周期时间

网络编程入门

Socket

  • 常用socket类型:

    • UNIX Domain Socket:主机内进程间通信
    • TCP/UDP Socket:跨网络的进程间通信
    • Netlink Socket:进程与内核的通信
  • socket地址类型:

在这里插入图片描述

  • socket API原本是为网络通讯设计的,但后来在socket的框架上发展出一种IPC机制,就是UNIX Domain Socket。虽然网络socket也可用于同一台主机的进程间通讯(通过loopback地址127.0.0.1),但是UNIX Domain Socket用于IPC更有效率:不需要经过网络协议栈,不需要打包拆包、计算校验和、维护序号和应答等,只是将应用层数据从一个进程拷贝到另一个进程。这是因为,IPC机制本质上是可靠的通讯,而网络协议是为不可靠的通讯设计的。UNIX Domain Socket也提供面向流和面向数据包两种API接口,类似于TCP和UDP,但是面向消息的UNIX Domain Socket也是可靠的,消息既不会丢失也不会顺序错乱。

  • UNIX Domain Socket是全双工的,API接口语义丰富,相比其它IPC机制有明显的优越性,目前已成为使用最广泛的IPC机制,比如X Window服务器和GUI程序之间就是通过UNIX Domain Socket通讯的。

  • 使用UNIX Domain Socket的过程和网络socket十分相似,也要先调用socket()创建一个socket文件描述符,address family指定为AF_UNIX,type可以选择SOCK_DGRAM或SOCK_STREAM,protocol参数仍然指定为0即可。

  • UNIX Domain Socket与网络socket编程最明显的不同在于地址格式不同,用结构体sockaddr_un表示,网络编程的socket地址是IP地址加端口号,而UNIX Domain Socket的地址是一个socket类型的文件在文件系统中的路径,这个socket文件由bind()调用创建,如果调用bind()时该文件已存在,则bind()错误返回。

  • 网络字节序

    • 网络通信使用的字节序(大端)

    • 字节序是数据在计算机硬件中的存储方式。

    • 大端字节序:高位字节在前,低位字节在后,即内存低地址保存高位字节,高地址保存低位字节。例如0x12345678,保存顺序为“12 34 56 78”,与人类对数据的阅读方式相同。

    • 小端字节序:低位字节在前,高位字节在后,即内存低地址保存低位字节,高地址保存高位字节。例如0x12345678,保存顺序为“78 56 34 12”,与人类对数据的阅读方式相反。

在这里插入图片描述

  • 与主机字节序(大端/小端)之间的转换:

    • 发送数据前转换成网络字节序
    • 接收数据后转换成主机字节序
  • socket编程模型

    • 所有socket类型的编程模型基本一致
    • 两种编程模式:无连接 VS. 面向连接
      • TCP:面向连接(SOCK_STREAM),流式传输,无长度限制
      • UDP:无连接(SOCK_DGRAM),数据报传输,报文大小固定
      • domain socket:两种模式都支持
    • 两种通信方式:阻塞/非阻塞
      • 默认阻塞
  • 无连接的socket通信过程

在这里插入图片描述

  • 面向连接的socket通信过程

在这里插入图片描述

如果不需要传递flag,也可以使用read/write接口

  • socket接口在协议栈中的位置

在这里插入图片描述

  • 多路I/O复用

    • 为什么需要多路选择IO?

      • 灵活编程:阻塞 / 非阻塞 / 超时等待
      • 可以同时检测多个等待的I/O设备
      • 支持大量客户端并发连接
    • 有哪些API可用于多路选择IO?

      • select

        • int select(nfds, readfds, writefds, exceptfds, timeout)
      • fd_set:与监视文件建立一一映射

        • 最大可监听文件数:1024 / 2048
      • 最常用的fd_set:readfds

        • client msg到达
          • read()
          • recv()
        • server也可以加入readfds
          • client connect()
          • server accept()
        • 超时时间:可以精确到微秒
        • FD_ZERO
        • FD_SET
        • FD_ISSET

在这里插入图片描述

      void foo(void)
      {
          ...;
          int server_fd, client_fd = -1;
      
          /* Server socket creating & binding here, and do listening */
          ...;
      
          while (1) {
              struct timeval timeout = ...;
              fd_set rfds;
              int ret, nready;
              int max_fd;
      
              FD_ZERO(&rfds);
              FD_SET(server_fd, &rfds);
              if (client_fd >= 0)
                  FD_SET(client_fd, &rfds);
              max_fd = (server_fd >= client_fd ? server_fd : client_fd);
              nready = select(client_fd + 1, &rfds, NULL, NULL, &timeout);
              if (nready < 0) {
                  src_printf(SRC_ERR, "select error: %s\n", strerror(errno));
                  /* You can add your error handler here */
                  sleep(1);
                  continue;
              } else if (nready == 0) {
                  /* Do your timeout handler here */
                  ...;
                  continue;
              }
      
              if (FD_ISSET(server_fd, &rfds)) {
                  src_printf(SRC_DEBUG, "Accepting...");
                  /* client connecting server, do accept() */
                  ...;
                  if (--nready <= 0)
                      continue;
              }
              if (FD_ISSET(client_fd, &rfds)) {
                  /**
                   * client could be read.
                   * You can read() or recv() msg here.
                   */
                  ...;
              }
          }
          ...
      }
  • select()的机制中提供一fd_set的数据结构,实际上是一long类型的数组,每一个数组元素都能与一打开的文件句柄(不管是Socket句柄,还是其他文件或命名管道或设备句柄)建立联系,建立联系的工作由程序员完成,当调用select()时,由内核根据IO状态修改fd_set的内容,由此来通知执行了select()的进程哪一Socket或文件可读,下面具体解释:
        #include <sys/types.h>
        #include <sys/times.h>
        #include <sys/select.h>
        
        int select(nfds, readfds, writefds, exceptfds, timeout)
        int nfds;
        fd_set *readfds, *writefds, *exceptfds;
        struct timeval *timeout;
        
        ndfs:select监视的文件句柄数,视进程中打开的文件数而定,一般设为你要监视各文件中的最大文件句柄加一。 
        readfds:select监视的可读文件句柄集合。
        writefds: select监视的可写文件句柄集合。
        exceptfds:select监视的异常文件句柄集合。
        timeout:本次select()的超时结束时间。(见/usr/sys/select.h,可精确至百万分之一秒!) 
         
        当readfds或writefds中映射的文件可读或可写或超时,本次select()就结束返回。程序员利用一组系统提供的宏在select()结束时便可判断哪一个文件可读或可写。对Socket编程特别有用的就是readfds。 
        几个相关的宏解释如下:
        
        FD_ZERO(fd_set *fdset):清空fdset与所有文件句柄的联系。
        FD_SET(int fd, fd_set *fdset):建立文件句柄fd与fdset的联系。
        FD_CLR(int fd, fd_set *fdset):清除文件句柄fd与fdset的联系。
        FD_ISSET(int fd, fdset *fdset):检查fdset联系的文件句柄fd是否可读写,>0表示可读写。
        (关于fd_set及相关宏的定义见/usr/include/sys/types.h)
  • poll

  • epoll

    • int epoll_create(int size)
    • int epoll_create1(int flags)
    • int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
    • int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)
    • epoll用法参考:《epoll详解》https://blog.csdn.net/X_Nazgul/article/details/22663715
    • 在这里插入图片描述

    +在这里插入图片描述

  • socket编程常用API

    • socket创建、连接与数据收发
      • socket
      • bind
      • listen
      • connect
      • accept
      • send / sendto / sendmsg
      • recv / recvfrom / recvmsg
      • close / shutdown
    • IP地址转换
      • inet_addr
      • inet_aton
      • inet_ntoa
    • 字节序转换
      • htons / htonl
      • ntohs / ntohl
    • 网络参数检索与设置
      • getsockname
      • getpeername
      • getsockopt
      • setsockopt
    • 多路I/O复用
      • select
      • poll
      • epoll

编写systemd服务

  • Linux下的一种init程序

    • 用于取代老旧的System V init
    • 什么是init进程?
  • System V init VS. system

    • System V init:串行启动服务

      • 功能简单
      • 启动时间长
      • 启动脚本复杂
        • /etc/init.d/*
        • rcS, rc0~rc6
    • systemd:并行启动服务

      • 功能强大的系统服务管理器
      • 启动速度快
      • 不需要编写启动脚本
        • foo.service

在这里插入图片描述

  • foo.service文件结构

    • [Unit]

      • 服务说明
      • 服务启动顺序和依赖关系定义
    • [Service]

      • 服务运行参数设置
      • 文件路径要求必须是绝对路径
      • start/stop/restart
    • [Install]

      • 服务安装相关设置
      • enable/disable
      [Unit]
      Description=foo service description
      After=network.target
      [Service]
      Type=forking
      PIDFile=/var/run/foo.pid
      ExecStart=/usr/local/bin/foo
      Restart=always
      [Install]
      WantedBy=multi-user.target
      
    • 服务foo.service文件存放路径

      • /lib/systemd/system/foo.service
  • 常用服务控制命令

    • systemctl start foo
    • systemctl stop foo
    • systemctl restart foo
    • systemctl enable foo
    • systemctl disable foo
  • Systemd 入门教程

    • http://www.ruanyifeng.com/blog/2016/03/systemd-tutorial-commands.html
    • http://www.ruanyifeng.com/blog/2016/03/systemd-tutorial-part-two.html
posted @ 2022-08-28 23:34  main_c  阅读(179)  评论(0编辑  收藏  举报