Skip to content

线程与进程

对于我们开发来说,现在主流是C++标准提供的多线程了,linux系统调用提供的多线程功能不作为重点,多进程用的比较少,但理解进程还是非常有意义,对于操作整个linux系统非常重要。

线程介绍

线程就是一种轻量级进程,本质仍然是进程。并不违背进程是linux系统最小执行单元的说法。

我们接下来看一下这个轻量究竟体现在什么地方:

线程有独立的PCB,但没有独立的进程地址空间。在引入线程的概念后,进程可以理解为只有一个线程的进程,那个线程拥有了进程地址空间的全部资源。

一句话来说,线程相对进程的轻量就体现在它有自己的pcb,但一个进程下的不同线程要公用一个地址空间

多线程和多进程

如果真要用linux的系统调用和C函数库来写程序。那就来对比一下多线程和多进程的优缺点。

  • 多线程:线程之间的通信更灵活方便,线程的创建,销毁成本相比进程更低。
  • 多进程:健壮性,稳定性更强

进程介绍

在linux系统中,进程是最小的执行单元,也就是说,linux系统任何任务,都要依靠进程来执行。

每个进程都有一个进程控制块(PCB)一个进程所有的信息都存放在这个PCB中,比如进程状态,内存指针,文件描述符表等。

其实这么说有些不准确,pcb中很多只是指针,pcb中指针指向的部分很多并不在pcb中,比如进程描述符表其实就并不在pcb中,pcb只有指向进程描述符表的指针

在32位linux系统中,一个进程有4g的可用空间。这个可用空间被分为内核区(1G),用户区(3G)。

进程内核区

所有的进程共用一个内核区。内核区就对应linux系统的内核区。

那么便有文件系统,网路系统,内存管理系统,进程管理系统等。

所以一个进程可以非常方便的使用linux系统内核区的东西。

可以通过ps命令来查看进程的属性,进程的属性是比较重要的。

常见命令ps -efps -auxps -o
描述显示所有进程的信息。显示所有进程的信息,要比ps -ef显示的信息多一些可以指定进程显示的信息

进程用户区

用户区主要由以下组成。

栈区

进程函数需要在栈上运行

代码区

进程运行所需要的代码就存储在这个区域

动态库加载区

在进程启动时加载必要的动态库

静态变量区和全局变量区

实际上这个区还可以分为两部分。

  • bss区:用来存储未初始化的全局变量,所以未初始化的全局变量均为0.
  • 数据区:存储已经初始化的全局变量和静态变量。对于静态变量来说,未初始化只会有个未定义的初值

环境变量区与命令行参数区

存储进程的环境变量与命令行参数。

堆区

堆区可以认为最特殊的区域,其它区域在进程启动时就已经被划分,而堆区只有在进程需要时才会分配。

所以堆区又名动态运行区,是程序运行的主要区域。

堆区的大小也是不固定的,理论上,只要用户区使用的区域没有达到3g,就仍然可以申请堆区。

创建进程

在linux系统中,创建进程依赖fork函数。

cpp
int main() {
  pid_t pid = fork();
  if(pid == -1) {
    perror("fork func error");
    return -1;
  }else if(pid == 0) {
    std::cout << "child process, child process id is : " <<getpid()<<std::endl;
  }else {
    std::cout << "parent process, parent process id is : " <<pid<<std::endl;
  }
  return 0;
}
  • 子进程会继承父进程所有能继承的东西(如工作目录,文件掩码,文件描述符表)
  • 不能继承的: pid,ppid,异步的输入输出,锁定内存等。
  • 其实记能继承什么,不能继承什么完全没有意义。
  • 等碰到时,理解一下这是什么概念,再想一想能不能继承,如果能继承,就继承了。
  • 记自己不理解的东西效果是非常差的。
cpp
int main() {
  pid_t pid = 0;
  unsigned i = 0;
  for(i = 0; i < 5; ++i) {
    pid = fork();
    if(pid == -1) {
      perror("fork func error");
      return -1;
    }
    if(pid == 0) break;
  }
  switch(i) {
    case 0:
      std::cout<<"first child process"<<std::endl;
      break;
    case 1:
      std::cout<<"second child process"<<std::endl;
      break;
    case 2:
      std::cout<<"third child process"<<std::endl;
      break;
    case 3:
      std::cout<<"forth child process"<<std::endl;
      break;
    case 4:
      std::cout<<"fifth child process"<<std::endl;
      break;
    case 5:
      std::cout<<"parent process"<<std::endl;
      break;
    default:
      break;
  }
  return 0;
}

控制进程

fork创建子进程后执行的是和父进程相同的程序。子进程也可以执行自己特有的程序。

字符lpve
含义命令行参数环境变量中的PATH参数数组环境变量

exec函数族

当一个进程调用exec函数族中的函数时,该进程的用户空间代码和数据完全被新程序替换。子进程便可执行自己特有的程序。

exec函数并不创建新的进程,所以原进程的pcb并不会发生改变(这里用pcb代指进程内核区的全部信息了

exec函数族共有6位成员,分别是 (1)int execl (const char* path, const char* arg, ...);

cpp
int main() {
  pid_t pid = fork();
  if (pid == -1) {
    perror("fork func error");
    return -1;
  }else if(pid > 0) sleep(1);
  else {
    if(execl("/bin/ls","ls","-al",NULL) == -1) {
      perror("execl func error");
    }
  }
}

(2)int execlp(const char* path, const char* arg, ...);

cpp
int main() {
  pid_t pid = fork();
  if (pid == -1) {
    perror("fork func error");
    return -1;
  }else if(pid > 0) sleep(1);
  else {
    if(execlp("ls","ls","-al",NULL) == -1) {
      perror("execlp func error");
    }
  }
}

(3)int execle(const char* path, const char* args ..., char* const envp[]);

cpp
#include <iostream>
#include <unistd.h>
//在linux中进程的环境变量储存在这样一个数据结构中char* argv[]
//指向的是char* argv[]第一个元素
extern char** environ;

// environ.cpp
int main() {
  for(char** ptr = environ; *ptr != nullptr;++ptr) {
    std::cout<< *ptr <<std::endl;
  }
  return 0;
}
cpp
int main() {
  pid_t pid = fork();
  if (pid == -1) {
    perror("fork func error");
    return -1;
  }else if(pid > 0) sleep(1);
  else {
    char* environArr[]{const_cast<char*>("A=1"),const_cast<char*>("B=2"),NULL};
    if(execle("./environ","environ",NULL,environArr) == -1) {
      perror("execle func error");
    }
  }
}

(4) int execv(const char* file, char* const argv[]);

cpp
int main() {
  pid_t pid = fork();
  if (pid == -1) {
    perror("fork func error");
    return -1;
  }else if(pid > 0) sleep(1);
  else {
    char* paramArr[]{const_cast<char*>("ls"),const_cast<char*>("-al"),NULL};
    if(execv("/bin/ls",paramArr) == -1) {
      perror("execv func error");
    }
  }
}

(5) int execvp(const char* file, char* const argv[]);

cpp
int main() {
  pid_t pid = fork();
  if (pid == -1) {
    perror("fork func error");
    return -1;
  }else if(pid > 0) sleep(1);
  else {
    char* paramArr[]{const_cast<char*>("ls"),const_cast<char*>("-al"),NULL};
    if(execvp("ls",paramArr) == -1) {
      perror("execv func error");
    }
  }
}

(6) int execlpe(const char* file, char* const argv[], char* const envp[]);

在比较新的版本中已经被舍弃了

环境变量

一个进程有些属性是经常使用的。比如对于shell程序,LD_LIBRARY_PATHPATH这些变量是经常需要设置的。

如果将这些变量放置在栈上,那会比较繁琐,不方便。于是进程搞出来了环境变量区命令行区,就是一个与堆区,栈区类似的区域。

所有的环境变量都存放在这个区域中,我们常用的PATHLD_LIBRARY_PATH都是bash进程环境变量区中的变量。

子进程经常需要使用父进程的环境变量,默认情况下,子进程直接继承父进程的环境变量。

守护进程(服务)

介绍

不属于任何一个终端,为整个系统提供支持的进程,比如httpd服务,整个系统每个终端都可以使用。比如最重要的nginx,redis都是守护进程。

特点

  • 生命周期长,经常设置为开机自启,然后直到系统关闭才结束。
  • 不属于任何终端,一般任何终端都可以使用,任何终端的关闭也不会导致守护进程的关闭。

创建流程

  • fork创建子进程,此时该子进程仍然属于该会话。
  • 调用setsid函数,子进程创建一个新的会话,该会话不属于任何终端。这个命令可以简单理解为子进程调用了这个函数,该进程就脱离了会话,从此该进程就自由了。
  • 设置工作目录:chdir(/tmp)。
  • 设置文件掩码:方便后面守护进程创建文件,子进程会继承父进程所有能继承的东西,就包括文件掩码。文件掩码是什么大家百度一下啊。
  • 关闭从父进程继承来到文件描述符表。(如果不明白,就完后看吧,管道那里详细说了继承文件描述符表是什么意思)
cpp
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>


int main() {
  pid_t pid = fork();
  if (pid == -1) {
    perror("fork func error");
    return -1;
  }else if(pid > 0) {
  }else {
    if(setsid() == -1) {
      perror("setsid func error");
      return -1;
    }
    if(chdir("/tmp") == -1) {
      perror("chdir func error");
      return -1;
    }
    umask(0);
    for(unsigned i = 0;i < getdtablesize();++i) {
      close(i);
    }
    sleep(20);
  }
  return 0;
}

工作目录

进程的所有相对路径名称都是从进程的工作目录开始解释的。进程默认的工作目录就是启动进程时的目录。

为了使守护进程的操作更加灵活, 便要更改工作目录。

假设我们在/home/flowers/tmp目录下启动守护进程,然后该目录已经没用了,我们又想要删除它。

但进程的工作目录所在的目录是不能删除的,所以将进程的工作目录移动到//tmp就非常有必要了。

如果我们在/mnt/usb这种目录启动进程,那么更改进程工作目录意义就会更大。

孤儿进程

父进程先于子进程结束,则子进程成为孤儿进程,子进程的父进程成为init进程。这一过程称为init进程领养孤儿进程。

因为父进程会被父进程的父进程wait处理,所以并不会导致进程的PCB无法释放,并没有危害。

但孤儿进程仍然应该避免,这容易导致整个系统设计混乱,在项目比较大的时候尤其明显。

僵尸进程

进程终止,父进程尚未回收资源,子进程的残留资源仍然存在,被称为僵尸进程。

可能很多人就有疑问了,为什么子进程结束后不直接自我销毁呢?这是linux系统的设计理念,linux系统的进程结构就是倒树状,父进程需要对子进程负责。如果子进程结束后自动销毁,会导致整个系统的混乱。

注意:僵尸进程的危害非常大,在实际项目中父进程经常需要长时间运行,子进程的pcb长期无法释放,会导致内核区被子进程的pcb占满,再也无法创建内核区对象了。

wait函数

wait函数其实是用来处理僵尸进程的。wait函数的作用是立马阻塞该进程,等到某个子进程退出就会立马处理该子进程,防止子进程变为僵尸进程。

cpp
int main() {
  pid_t pid = fork();
  if (pid == -1) {
    perror("fork func error");
    return -1;
  }else if(pid > 0) {
    if(wait(nullptr) == -1) {
      perror("wait func error");
      return -1;
    }
    sleep(15);
  }else {

  }
}

waitpid函数

和wait函数功能相似,只不过比wait函数灵活一些。

在实际项目中,还是wait函数用的比较多,更加简单,实用。

cpp
int main() {
  pid_t pid = fork();
  if (pid == -1) {
    perror("fork func error");
    return -1;
  }else if(pid > 0) {
    // WNOHANG不阻塞 pid表示只等待子进程 其他进程不管
    // if(waitpid(pid,nullptr,WNOHANG) == -1) {
    //   perror("wait func error");
    //   return -1;
    // }

    // 这样和wait(nullptr)没区别
    if(waitpid(-1,nullptr,0) == -1) {
      perror("wait func error");
      return -1;
    }
    sleep(15);
  }else {

  }
}

用信号处理僵尸进程

直接将父进程kill掉,这在绝大多数项目中当然是不行的,我们还指望父进程完成任务呢。

在真正处理僵尸进程时,我们不会直接使用wait或waitpid函数。

父进程调用wait函数,但这样父进程就阻塞了,严重浪费资源。

waitpid函数也比较麻烦,如果设置为阻塞状态会存在和wait函数同样的问题。

不阻塞又涉及到进程间通信了,比较麻烦。

而普遍使用的处理僵尸进程的方法需要wait函数和信号配合使用,到时再介绍通用的方法。

多进程项目中真正使用的处理僵尸进程的方法

其实这么写是很不标准的,可以去百度一下标准的。

cpp
void SIGCHILD_handler_func(int sigChild) {
    if(wait(nullptr) == -1) {
        perror("wait func error");
        exit(1);
    }
}

int main() {
    pid_t pid = fork();
    if (pid == -1) {
        perror("fork func error");
        return -1;
    }else if(pid > 0) {
        signal(SIGCHLD, SIGCHILD_handler_func);
        while(1) {
            sleep(1);
            std::cout<< "hello world"<<std::endl;
        }
    }else {  
    }
}

比较标准的写法及其说明

  • 信号处理函数 handle_sigchld: 使用 waitpid(-1, nullptr, WNOHANG) 循环回收所有已退出的子进程,避免产生僵尸进程。

  • 设置 sigaction: 通过 sigaction 注册 SIGCHLD 信号处理函数,并使用 SA_RESTART 保证在信号发生后被中断的系统调用能自动重启,SA_NOCLDSTOP 则避免在子进程暂停时也触发 SIGCHLD 信号。

  • 子进程分支: 子进程打印信息,等待一段时间(模拟工作)后退出。

  • 父进程分支: 父进程进入一个无限循环,每秒打印一次 hello world,同时 SIGCHLD 信号的到来会触发信号处理函数,从而回收子进程。

cpp
#include <iostream>
#include <csignal>
#include <sys/wait.h>
#include <unistd.h>
#include <cstdlib>
#include <cerrno>

// 信号处理函数:回收所有退出的子进程
void handle_sigchld(int /*sig*/) {
    int saved_errno = errno; // 保存errno,避免修改全局errno
    // 循环调用waitpid回收所有已退出的子进程
    while (waitpid(-1, nullptr, WNOHANG) > 0) { }
    errno = saved_errno; // 恢复errno
}

int main() {
    // 设置sigaction,注册SIGCHLD信号处理函数
    struct sigaction sa{};
    sa.sa_handler = handle_sigchld;
    sigemptyset(&sa.sa_mask);
    // SA_RESTART自动重启被信号中断的系统调用,SA_NOCLDSTOP不对停止状态的子进程发送信号
    sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
    if (sigaction(SIGCHLD, &sa, nullptr) == -1) {
        perror("sigaction error");
        exit(EXIT_FAILURE);
    }

    // 创建子进程
    pid_t pid = fork();
    if (pid == -1) {
        perror("fork error");
        exit(EXIT_FAILURE);
    } else if (pid == 0) {
        // 子进程代码
        std::cout << "Child process is running." << std::endl;
        sleep(2);  // 模拟子进程工作
        std::cout << "Child process exiting." << std::endl;
        exit(EXIT_SUCCESS);
    } else {
        // 父进程代码:持续打印`hello world`
        while (true) {
            std::cout << "hello world" << std::endl;
            sleep(1);
        }
    }

    return 0;
}

进程组

进程组主要是为信号服务的。信号都是以进程组为单位的。

在linux中一个任务经常需要多个进程配合才可以完成,这些进程组合而成的整体就是一个进程组,每个进程组都有一个进程组长,进程组的第一个进程就是进程组长。

如果没有进程组,内核要关闭一个程序,需要给组成这个程序的每个进程发送信号以关闭进程。而有了进程组关闭一个程序就方便多。

操作系统内核只要给进程组发送一个终止的信号,进程组组长在接收到信号后就会把信号发送给进程组的其它进程,其它进程都会关闭,然后进程组组长自己再关闭。

这样就极大地减少了操作系统内核区的工作量,内核区是比较忙的,操作系统的性能瓶颈一般在内核区,所有减少内核区的工作量是至关重要的。

会话

多个进程组就组成了一个会话,一个控制终端对应一个会话。

进程间通信

对于多进程程序来说,进程间通信是绝对的核心,进程间通信还是应该掌握的。毕竟多进程对于理解linux系统非常重要。

概念

在linux系统中,进程间的通信很重要,因为linux中常常需要一个进程组来完成任务,多个进程要相互配合,才能完成一个项目。这个配合过程自然就非常重要了。

进程之间通信的本质就是让不同的进程看到同一份资源。由于不同进程相互独立,没有什么数据是共享的,所以进程之间的通信必须依靠第三方资源

不同的第三方资源就有了不同的通信方式。但大部分都被弃用了,这里就只列举几个比较常用的。

注意:虽然说进程间通信的方式有很多,但会这几个绝对够用了。

管道mmap (memory map,内存映射)信号本地网路消息队列

管道

管道是丛unix继承而来的通信方式,非常古老。其思想是,在内存中创建一个共享文件,从而使两个进程利用这个共享文件来传递信息。

由于这种方式具有单向传递的特点,所以这个传递消息的共享文件就叫做管道。根据这个共享文件是否有名称,可以划分为命名管道匿名管道

匿名管道

(1)pipe函数可以创建一个文件,这个文件的特点就是一个文件有两个inode,返回两个文件描述符。

(2)介绍一下管道文件和普通文件的区别,管道文件是一种伪文件,所有的数据只存储在内存中,而不涉及磁盘。所以使用管道进行进程间通信的效率要远远高于使用文件进行通信。使用普通文件进行通信的方式现在已经不再使用了。

(3)这两个文件描述符一个可以读文件,一个可以写文件。这时可能很多人就有疑问了,为什么不可以设置为每一个文件描述符既可以读文件又可以写文件呢?

因为这两个文件描述符是要被复制到子进程中的。两个进程共同操作一个文件,是要涉及到共享数据问题的,就和多线程数据保护一样。管道文件没有复杂的控制流程,就只能一个读一个写了。

(4)子进程会复制父进程所有能复制的东西,就包括文件描述符以及文件描述符对应的inode。所以此时父子进程都可以方便的访问这个共享文件了。

我相信很多人对子进程继承父进程的文件描述符还不清楚,子进程继承的是父进程的文件描述符表以及文件描述符表对应的indoe。也就是说文件的全部属性都继承了(文件内容不属于文件属性,文件内容在硬盘上)

所以匿名管道只能在对应管道的文件描述符相同的进程间使用。

cpp
int main() {
    int pipeFd[2]{};
    if(pipe(pipeFd) == -1) {
        perror("pipe func error");
        return -1;
    }
    pid_t pid = fork();
    if(pid == -1) {
        perror("fork func error");
        return -1;
    }else if(pid > 0) {
        close(pipeFd[0]);
        const char* str = "hello world";
        if(write(pipeFd[1], str, strlen(str)) == -1) {
            perror("write func error");
            return -1;
        }
        close(pipeFd[1]);
    }else {
        close(pipeFd[1]);
        char readBuf[32]{};
        if(read(pipeFd[0], readBuf, 32) == -1) {
            perror("read func error");
            return -1;
        }
        std::cout << readBuf << std::endl;
        close(pipeFd[0]);
    }
    return 0;
}

命名管道

  • 匿名管道由于没有名称,只能在一些具有亲缘关系的进程直接传递信息。而命名管道是一个真正独立的文件。可以在任意两个进程之间进行通信
  • 命名管道不支持文件定位操作,严格遵守先进先出的原则。所以命名管道也被称为FIFO文件(first in first out)。
  • 相比于普通文件,命名管道由于严格遵守先进先出的原则。所以不涉及多进程共享访问资源的问题。
cpp
int main() {
    int pipeFd[2]{};
    if(pipe(pipeFd) == -1) {
        perror("pipe func error");
        return -1;
    }
    pid_t pid = fork();
    if(pid == -1) {
        perror("fork func error");
        return -1;
    }else if(pid > 0) {
        if(mkfifo("tmp",0644) == -1) {
            perror("mkfilo func error");
            return -1;
        }
        int inFifoFd = open("tmp", O_WRONLY);
        if (inFifoFd == -1) {
            perror("open inFifoFd error");
            return -1;
        }
        const char* str = "hello world";
        if(write(inFifoFd, str, strlen(str)) == -1) {
            perror("write func error");
            return -1;
        }
        close(inFifoFd);
    }else {
        sleep(1);
        int outFifoFd = open("tmp",O_RDONLY);
        if(outFifoFd == -1) {
            perror("ioen outFifoFd error");
            return -1;
        }
        char readBuf[32]{};
        if (read(outFifoFd, readBuf, 32) == -1) {
            perror("read func error");
            return -1;
        }
        std::cout << readBuf << std::endl;
        close(outFifoFd);
    }
    return 0;
}

mmap

mmap是memory map的简写,也就是内存映射。文件映射区是和堆区,栈区类似的区域。

内存映射可以理解为另一种读写文件的方式。内存映射和mmap的东西有很多。

将一个文件(主要是文件)映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对应关系。

cpp
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
  • addr: 映射区的起始地址。通常设置为 NULL,让系统自动选择合适的地址。
  • length: 映射区的长度,以字节为单位。
  • prot: 映射区的保护权限,可以是以下值的组合:
    • PROT_READ: 可读
    • PROT_WRITE: 可写
    • PROT_EXEC: 可执行
    • PROT_NONE: 不可访问
  • flags: 映射区的属性,可以是以下值的组合:
    • MAP_SHARED: 共享映射,对映射区的修改会反映到文件中。
    • MAP_PRIVATE: 私有映射,对映射区的修改不会反映到文件中。
    • MAP_ANONYMOUS: 匿名映射,不与任何文件关联,用于进程间共享内存。
  • fd: 文件描述符,指定要映射的文件。如果是匿名映射,则设置为 -1。
  • offset: 文件偏移量,从文件的哪个位置开始映射。

munmap

mmap是在进程中增加一块内存映射区, munmap就是在进程中把这块内存映射区取消掉。

cpp
int main() {
    struct stat st{};
    int fd = open("num.txt", O_RDWR);
    if (fd == -1) {
        perror("open num.txt error");
        return -1;
    }
    if (fstat(fd, &st) == -1) {
        perror("fstat func error");
        return -1;
    }
    char* mmapAddr = static_cast<char*>(mmap(nullptr, st.st_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0))
    if(mmapAddr == MAP_FAILED) {
        perror("mmap func error");
        return -1;
    }
    close(fd);
    // strncpy(mmapAddr, "hello world", 12); 写
    // std::cout << mmapAddr <<std::endl; 读
    if( munmap(mapAddr, st_st.size) == -1) {
        perror("munmap func error");
        return -1;
    }
    return 0;
}

管道与mmap

管道

管道简单,方便。但速度比较慢,而且管道的容量有限。速度慢主要因为管道文件内部加了互斥锁。

使用ulimit -a可以查看pipe size。如果达到了大小限制,将无法进行write操作。

mmap

mmap方便控制大小,速度快,操作灵活。但文件不能太大,因为mmap相当于在进程的运行空间中分配了一个区域。

理论上只要不占满进程用户区,mmap就可以继续扩大,但一般来说,mmap不要太大,常常32M,64M就到极限了。

而且mmap需要考虑进程间共享内存的问题,相当于多线程对数据保护的考虑。