linux下进程/线程/协程

305 阅读14分钟

linux下进程和线程的概念

操作系统书上说:进程是拥有资源的基本单位,而线程是调度的基本单位。在linux下,线程作为一种轻量级线程被实现。在操作系统看来,进程和线程并没有区别,换句话说,进程和线程都是使用同一个结构体来描述的。区别的是线程会共享创建它的进程的地址空间。

究其原因在于:进程的概念比线程要早得多,随着CPU的算力和的提升和核心数的增加。为了充分利用多核心的算力,人们开始使用多进程。但进程之间的切换要臃肿的多,相比线程它的额外开销在于地址空间的复制,由此引发的缓存失效更是增加了切换上下文的时间成本。故提出线程的概念,它更为轻量,仅仅拥有必要的运行上下文和线程栈。

但问题是之前的进程的状态转换、调度策略在linux内核中已经有现成的实现了。如果再引入单独的线程概念,无疑大大增加了内核的复杂度。在此背景下,轻量级进程(LWP)就此诞生。由于它们复用同样的数据结构,之前进程的调度策略等依然可以使用。

image.png

同一进程下的多个线程就类似上图,它们共享同一个地址空间。当然了进程还包含打开文件表、信号处理函数等。

线程共享以下进程的资源和环境:

1. 文件描述符表(重点) 2. 每种信号的处理方式(SIG_IGN、SIG_DFL或者自定义的信号处理函数) 3. 当前工作目录 4. 用户id和组id

线程有自己的私有数据:

1. 线程id 2. 上下文信息,包括各种寄存器的值、程序计数器和栈指针(重点) 3. 栈空间(临时变量存储在栈空间中)(重点) 4. errno变量 5. 信号屏蔽字 6. 调度优先级

用户态线程和内核线程

一个线程由于其运行空间的不同, 从而有内核线程和用户进程的区分, 内核线程运行在内核空间, 之所以称之为线程是因为它没有单独的虚拟地址空间, 只能访问内核的代码和数据, 它的相应指针是空的。而用户线程则运行在用户空间, 不能直接访问内核的数据但是可以通过系统调用等方式从用户态陷入内核态。

用户态线程的切换涉及到两次上下文的切换。当用户态线程通过系统调用进入内核态,它的运行上下文会保存在线程栈中。然后将返回地址和系统调用参数等上下文信息保存到内核栈中,而内核栈的信息则保存在pcb中。再通过调度程序选出下一个要运行的线程。找到/取出线程实质就是取出保存它信息的数据结构pcb。根据pcb中的信息找到新线程的内核栈,再根据内核栈的信息恢复它的用户态下的运行上下文。

image

核心级线程:由内核管理TCB

  1. 有两套栈,既能在用户态运行,也能到内核态运行。
  2. 切换的时候,切换一套栈,即同时切换用户栈和内核栈。
  3. TCB中保存的是内核栈的信息,内核栈中保存源程序中相关信息,也包括用户栈的SS和SP。

image.png 当发生内核级线程S切换到内核级线程T的时候

  1. 执行系统调用,并用线程S的(内核栈的指针保存在TCB中)内核栈保存用户态下的信息,包括PC和用户栈指针等寄存器信息;
  2. 在就绪序列中找到线程T的TCB,利用TCB中保存的内核栈指针找到内核栈,然后用内核栈中的信息返回线程T的执行。

image

image

那么此时的ThreadCreate需要完成哪些功能呢? 它只需要把新建线程的状态恢复到可以切换的样子即可。

  1. 给线程申请空间保存TCB;
  2. 把用户态线程的信息如SS:SP和CS:IP等寄存器信息保存到内核栈中;
  3. 内核栈的指针信息保存到TCB中;
  4. 修改TCB状态为就绪并放入就绪队列。

image

线程的三种模型

  1. 一个用户线程绑定一个内核线程。因为操作系统实际是不知道用户态线程的存在的,它只能通过保存在内核中的内核线程pcb来感知到内核态线程,这其实就是linux下默认的线程模型。
  • 优点:可以充分的利用核心,提高并发,当某个用户态发起系统调用而阻塞的时候可以运行另一个线程运行
  • 缺点:操作系统限制了内核线程数量,因此一对一的模型会让用户态线程数据收到限制;另一方面正如上面所说,线程的创建和切换都是由内核负责,开销较大
  1. 多个用户态线程绑定到一个内核线程上
  • 优点:用户线程的创建/运行/切换都由库函数负责,相比一对一模型,线程的切换速度要快的多
  • 缺点:当某个用户线程阻塞,所有的线程都无法执行。本质原因还是内核无法感知到这些用户线程,没法对它们进行调度
  1. 多个用户线程绑定到多个内核线程上,N:M
  • 优点:它结合上两个模型的优点,最大线程数变多,并发性更好
  • 缺点:它的调度比较复杂,且程序的调试也更加困难

内核线程拥有 进程描述符、PID、进程正文段、核心堆栈

用户进程拥有 进程描述符、PID、进程正文段、核心堆栈 、用户空间的数据段和堆栈

用户线程拥有 进程描述符、PID、进程正文段、核心堆栈,同父进程共享用户空间的数据段和堆栈

fork/vfork/clone系统调用

fork

fork只拷贝基本的信息,如复制父进程的页表和给子进程创建唯一的进程描述符。它通过写时拷贝技术推迟资源的拷贝,只有在需要写入的时候,数据才会被复制,从而使得各个进程拥有各自的拷贝。在此之前,以只读的方式共享同一份资源

vfork

除了不拷贝页表项外,vfork()系统调用和fork()的功能相同。子进程作为父进程的一个单独的线程在它的地址空间里运行,父进程被阻塞,直到子进程退出或执行exec()。并且子进程不能向地址空间写入

clone

fork()和vfork()实质上都是调用了clone这个系统调用。只不过通过不同的参数指明哪些资源是共享的,哪些不是共享的来实现不同的效果

实验

1. 打印"hello,world"

接下来我们先展示一个父进程和子进程打印一个"hello, world"的例子。

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

//输出两个"hello, world"  
int main() {
    pid_t pid = fork(); // 创建子进程
    if (pid == -1) {
        // 如果fork失败,则打印错误信息
        perror("fork failed");
        return 1;
    } 
    else if (pid == 0) {
        // 这是子进程,pid为0
        printf("hello, world\n");
    } 
    else {
        // 这是父进程,pid是新创建的子进程的ID
        wait(NULL); // 等待子进程结束
        printf("hello, world\n");
    }
    return 0;
}

可以看到两个在终端上输出了两个字符串,我们根据fork()的返回值判断是在父进程还是子进程,然后父子进程都输出了“hello, world"。那么使用系统调用clone创建子线程会怎样呢。

#include <stdio.h>
#include <stdlib.h>
#include <sched.h>
#include <unistd.h>

 
// 子线程执行的函数
int childFunc(void* args) {
    printf("hello, world\n");
    return 0; // 返回0表示成功
}

//只输出一个"hello, world"
int main() {
    // 分配栈空间,CLONE函数需要
    const int STACK_SIZE = 65536; // 定义栈的大小
    char *stack = (char*)malloc(STACK_SIZE);
    if (!stack) {
        perror("malloc");
        exit(1);
    }
    
    // 注意:栈是从高地址向低地址增长的,因此我们传递栈顶指针
    if (clone(childFunc, stack + STACK_SIZE, CLONE_VM | CLONE_VFORK, NULL) == -1) {
        perror("clone");
        exit(1);
    }

    // 释放分配的栈空间
    free(stack);
    return 0;
}

这里只会打印一个"hello, world"。需要注意的是:如同fork(),由clone()创建的新进程和父进程类似,只不过子进程在修改共享变量的时候并不会触发写时复制。并且与fork()不同的是,克隆生成的子进程继续运行时不以调用处为起点,转而去调用以参数func所指定的函数

观察代码我们发现,线程栈是使用malloc()从堆区申请的空间。并且栈的大小在创建线程的时候就确定了。下面是进程的内存模型: image.png 可以发现进程栈和线程栈实质是不同的两块区域

  • 进程栈的初始化大小由编译器计算确定,但它的大小并不是固定的。内核会根据入栈情况动态增长,最大限制一般是8M,我们可以使用ulimit -s查看

image.png

  • 线程栈则是在创建线程的时候根据传入参数确定的,(最大不能超过2M)并且不能动态增长。它是从进程的地址空间申请的一块内存区域,是线程私有的

2. 打印共享变量

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
    int x = 0;
    if(fork() == 0) {
        printf("x = %d\n", ++x);
    }
    else {
        printf("x = %d\n", --x);
    }
    return 0;
}
//输出
//x = -1
//x = 1

这个例子非常明显的展示了使用fork()产生的子进程会使用单独的地址空间,由操作系统通过写时复制帮我们复制修改的变量到子进程中。

3. 写入打开文件

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <fcntl.h> 
#include <string.h>


int main(void) {
    int fd;
    pid_t pid;
    // 打开(或创建)文件
    fd = open("example.txt", O_WRONLY | O_CREAT | O_APPEND, S_IRUSR | S_IWUSR);
    if (fd == -1) {
        perror("open");
        exit(EXIT_FAILURE);
    }

    // 创建子进程
    pid = fork();
    if (pid == -1) {
        perror("fork");
        exit(EXIT_FAILURE);
    }

    if (pid > 0) {
        // 父进程
        const char *parent_msg = "parent\n";
        write(fd, parent_msg, strlen(parent_msg));
        wait(NULL); // 等待子进程结束
    } else {
        // 子进程
        const char *child_msg = "children\n";
        write(fd, child_msg, strlen(child_msg));
        exit(EXIT_SUCCESS);
    }

    close(fd);
    return 0;
}

可以发现,使用fork()并不会复制所指向的文件。在这里fork()实现的类似于一种浅拷贝,它只复制那个指针而不是所指向的内容。可以预想到,如果一个进程打开了大量文件,写时复制复制文件速度会慢的多,并且完全有可能会复制大量不需要的文件。 运行之后可以看到,父子进程的输出会写到同一个文件中:

image.png

4. 对信号的处理

fork()命令创建的子进程会继承父进程对于信号的一些处理。如信号处理函数和信号屏蔽字。

#include <stdio.h>
#include <signal.h>
#include <unistd.h>
  
  
// 定义SIGINT信号的处理函数
void sigint_handler(int sig) {
    // 使用write函数而非printf,因为它是异步信号安全的
    write(STDOUT_FILENO, "SIGINT received\n", 16);
}


int main() {
    // 注册SIGINT的处理函数
    if (signal(SIGTERM, sigint_handler) == SIG_ERR) {
    perror("signal");
    return 1;
    }
    fork();

    // 让程序进入一个无限循环,等待信号的到来
    while(1);
    return 0;
}

运行程序可以看到,后台有两个进程:

image.png

各个信号对应的指令如下:

image.png

若使用kill指令给进程传递SIGTERM信号,可以看到:

image.png

终端输出了我们在信号处理函数的设置:

image.png

并且两个进程都依然运行,并没有被打断。

有一个很经典的问题就是:在c++中线程崩溃了,进程往往也跟着崩溃了,而java中为什么不会这样?其实本质上是java的jvm对相应的信号做了处理。而c++的程序是没有的,大部分的信号的默认处理方式都是退出进程。我们可以通过实验验证一下。

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

  
// 线程的运行函数
void* myThreadFun(void* arg) {
    printf("打印来自线程\n");
    while(1);
    return NULL;
}

 
int main() {
    pthread_t thread_id;
    printf("正在创建线程\n");
  
    // 创建线程
    if (pthread_create(&thread_id, NULL, myThreadFun, NULL) != 0) {
        printf("无法创建线程\n");
        exit(1);
    }

    // 等待线程完成
    pthread_join(thread_id, NULL);
    printf("线程结束\n");
    return 0;
}

使用ps -p [pid] -L我们可以查看某个特定进程的线程信息:

image.png

pid也就是进程号,LWP则是轻量级线程,也就是线程号,可以看到上面的线程它的进程号和线程号一致,它就是程序中的主线程。在我们没有设置任何的进程/线程处理函数的情况下,使用下面的程序给上述进程中的子线程发送信号:

#include <unistd.h>
#include <sys/syscall.h>
#include <signal.h>
#include <stdio.h>


int main() {
    pid_t tgid, tid;
    int sig = SIGTERM; // 定义要发送的信号
    // 这里需要替换为目标进程ID和目标线程ID
    tgid = 1871028;
    tid = 1871029;
    // 发送信号
    if (syscall(SYS_tgkill, tgid, tid, sig) != 0) {
    perror("tgkill");
    }

    printf("信号发送成功\n");
    return 0;
}

当信号发送成功之后可以看到,整个进程都退出了。也就是说:我们对进程中的子线程发送信号导致线程退出,整个进程也会退出,这符合我们的直觉。毕竟线程是共享它的进程的地址空间的,如果某个子线程因为崩溃退出了,那么很难保证整个进程的数据一致性/正确性

image.png

为了方便进行后续的实验,我们把对指定线程发送信号的系统调用tgkill封装成一个程序,代码如下:

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
#include <sys/syscall.h>


// 使用syscall直接调用tgkill
int tgkill(int tgid, int tid, int sig) {
    return syscall(SYS_tgkill, tgid, tid, sig);
}

 
int main(int argc, char *argv[]) {
    if (argc != 4) {
    fprintf(stderr, "用法: %s <进程ID> <线程ID> <信号>\n", argv[0]);
    return EXIT_FAILURE;
    }

    int pid = atoi(argv[1]); // 进程ID
    int tid = atoi(argv[2]); // 线程ID
    int sig = atoi(argv[3]); // 要发送的信号

    // 检查信号的有效性
    if (sig < 1 || sig >= NSIG) {
        fprintf(stderr, "无效的信号: %d\n", sig);
        return EXIT_FAILURE;
    }

    // 调用tgkill发送信号
    if (tgkill(pid, tid, sig) != 0) {
        perror("tgkill调用失败");
        return EXIT_FAILURE;
    }

    printf("信号 %d 已发送到进程 %d 的线程 %d\n", sig, pid, tid);
    return EXIT_SUCCESS;
}

那么我们创建一个线程并且在线程中设置了不同的线程处理函数,可以看到,最后的输出都是子线程中的设置的信号输出,这是因为线程和进程是共享共一个信号处理函数的:

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <signal.h>
#include <unistd.h>

// Handler for SIGTERM in the main thread
void main_thread_sigterm_handler(int signum) {
    printf("Main thread received SIGTERM\n");
    exit(0);
}

// Handler for SIGTERM in the child thread
void child_thread_sigterm_handler(int signum) {
    printf("Child thread received SIGTERM\n");
    exit(0);
}

// Function executed by child thread
void *child_thread_func(void *arg) {
    // Set signal handler for SIGTERM in the child thread
    signal(SIGTERM, child_thread_sigterm_handler);
    printf("Child thread\n");
    while(1);
    
    return NULL;
}

int main() {
    // Set signal handler for SIGTERM in the main thread
    signal(SIGTERM, main_thread_sigterm_handler);

    // Create child thread
    pthread_t child_thread;
    if (pthread_create(&child_thread, NULL, child_thread_func, NULL) != 0) {
        perror("pthread_create");
        exit(EXIT_FAILURE);
    }

    printf("Main thread\n");

    // Main thread keeps running until terminated
    while(1);

    return 0;
}

通过bash简要分析fork/exec

我们经常使用命令行来进行各种方便的操作,那么bash程序是怎样运行的呢。实质上bash也只是操作系统中的一个普通的应用程序。 它会接收我们在命令行中敲入的字符进行解析,然后使用fork()命令创建一个新的进程。根据解析的结果使用execve调用相应的程序去覆盖子进程。至于这种形式的命令ps -ef | grep xxx则是创建了两个子进程,并且不同的进程之间通过管道相连来传输信息。

image.png

可以发现ps和grep的父进程都是-bash。