【操作系统】进程和线程知识点总结

提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档


前言

参考教材:TCP/IP网络编程 ,现代操作系统原理,chatgpt
此博客的代码几乎都在linux平台下进行运行

一、进程和线程

1.1、进程

1.1.1、进程概念

在操作系统中,进程是一个执行中的程序实例。每个进程都有自己的一套程序代码,当前状态,以及它所使用的所有系统资源的集合。资源可能包括内存空间,输入和输出设备,文件和其他操作系统资源。
当你启动一个程序时,操作系统创建了一个新的进程来执行程序代码。在多任务操作系统中,这些进程并非一次只执行一个,而是由操作系统进行调度,看起来就像它们在同时运行。
每个进程都有一个或多个线程,这些线程是进程中的执行单位。一个进程中的所有线程共享该进程的资源,但每个线程有其自己的执行路径和当前状态。
操作系统负责管理和调度所有进程的执行,以有效地共享处理器时间,管理内存和其他资源,并提供进程之间的通信机制。

问:那比如我自己写了一个CPP文件运行,那它是进程还是线程呢?
当你运行你自己写的CPP文件时,操作系统会为它创建一个新的进程。这个进程包括运行你的程序所需要的所有资源,如程序代码,运行状态,内存等。
然而,进程内部会包含至少一个线程,用于执行程序代码。如果你的程序是单线程的,那么它在该进程内会有一个主线程进行操作。如果你的程序是多线程的,那么它可能会在同一进程内创建和管理多个线程。
所以,总的来说,你的程序是一个进程,这个进程中包含了一个或多个线程。

1.1.2、进程的创建

在操作系统中,进程的创建通常通过一系列的步骤进行,以下是这些步骤的简要概述:

分配内存:操作系统为新进程分配内存空间,以便存储程序代码,进程的运行状态,堆栈,以及其他需要的数据。

加载程序:操作系统将程序的代码从磁盘加载到新分配的内存空间中。

初始化进程控制块(Process Control Block, PCB):操作系统为新进程创建一个进程控制块,其中包含关于进程的重要信息,如进程状态(运行、就绪、阻塞等)、进程标识符(PID)、程序计数器(PC,用于跟踪进程当前执行到哪一行代码)、CPU寄存器、CPU调度信息、内存管理信息等。

设置状态:新进程被设置为“就绪”状态,意味着它已准备好并等待CPU进行执行。

插入就绪队列:新的就绪进程被插入到就绪队列中等待CPU调度。

以上步骤是简化的过程,实际中操作系统在创建进程时可能需要执行更多其他的步骤,例如处理权限、安全设置等。同时,具体的创建过程也会因操作系统的类型和设计而有所不同。例如,有些操作系统支持通过fork()系统调用复制现有进程以创建新进程。


操作系统的就绪队列是?:
在操作系统中,就绪队列是一种数据结构,用于存储处于就绪状态的进程。就绪状态的进程是指已经准备好运行,但正在等待CPU时间的进程。
就绪队列是进程调度的一个重要组成部分。操作系统的调度器会根据某种策略(如先进先出、优先级调度、轮转调度等)从就绪队列中选取一个进程分配给CPU执行。
值得注意的是,就绪队列并不是唯一的进程队列。例如,还有阻塞队列(存储等待某种事件的进程,如I/O操作完成)和挂起队列(存储被暂时移出主内存的进程)等。
实际上,就绪队列、阻塞队列等都是进程状态模型的一部分,它们帮助操作系统管理和调度进程。在这种模型中,进程会根据其状态和发生的事件在不同的队列之间移动。例如,当一个进程启动I/O操作时,它可能会从就绪队列移动到阻塞队列。当I/O操作完成后,它又会被移回就绪队列。


进程控制块(Process Control Block, PCB)是操作系统用于描述进程的基本信息和状态的数据结构。它包含了诸如进程标识符(PID)、进程状态(运行、就绪、阻塞等)、程序计数器(PC,用于跟踪进程当前执行到哪一行代码)、CPU寄存器、CPU调度信息(如优先级)、内存管理信息(如基址和界限寄存器)、账户信息(如CPU使用时间)和I/O状态信息等信息。
进程表是操作系统用来管理和追踪所有进程的数据结构。每个正在系统中运行或待运行的进程在进程表中都有一个对应的条目,这个条目就是进程控制块。因此,可以说进程表是由一个个进程控制块组成的。
中断向量是操作系统用来处理中断的一种数据结构。中断是一个异步事件,它会打断当前进程的正常执行流程,例如硬件故障、I/O完成、定时器超时等。每种中断都有一个特定的中断处理程序来处理。中断向量是一个数组或表,它的每一个元素都是一个指向特定中断处理程序的指针。当中断发生时,系统会通过查询中断向量来找到对应的中断处理程序,然后跳转到该程序执行,以此来响应中断。
总的来说,进程控制块、进程表和中断向量都是操作系统用来管理进程和中断的关键数据结构。其中,进程控制块和进程表主要用于进程管理,而中断向量则用于中断处理。


进程创建的实例代码(C语言):
在C语言中,创建新的进程通常使用fork()函数。以下是一个创建新进程的示例:

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

int main() {
    
    
    pid_t pid = fork();

    if(pid == 0) {
    
    
        // 在子进程中执行
        printf("This is child process, pid = %d\n", getpid());
    } else if (pid > 0) {
    
    
        // 在父进程中执行
        printf("This is parent process, pid = %d, child pid = %d\n", getpid(), pid);
    } else {
    
    
        // fork()函数返回负值表示出错
        printf("fork() error!\n");
    }

    return 0;
}

fork函数的使用规则:

#include <unistd.h>
pid_t fork(void);

这个函数会创建一个新的进程,这个新进程是当前进程的复制品。在父进程中,fork()函数返回新创建的子进程的PID(一个正整数)。在子进程中,fork()函数返回0。如果fork()函数返回一个负值,那就表示创建新进程失败。

<unistd.h> 是Unix标准库的头文件,其中定义了很多在POSIX操作系统(如Linux和Unix)中常用的函数和宏,包括 fork()、getpid() 和其他一些系统调用。

举个例子,假设你有一个进程,它的进程ID是123,然后它调用了 fork() 函数。这时,系统创建了一个新的进程,它的进程ID是456,这个进程是进程123的复制品。然后 fork() 函数在进程123中返回456,在进程456中返回0。

1.1.3、进程的销毁

什么是僵尸进程,所有进程运行结束之后都是僵尸进程吗?

僵尸进程是指已经结束运行,但是父进程还没有读取其退出状态的子进程。僵尸进程的资源已经被释放,但是在进程表中仍然保留一个表项,等待父进程读取其退出状态。
在UNIX或Linux系统中,每个子进程在结束后都会变成一个僵尸进程,直到父进程读取了它的退出状态。父进程通过调用wait()或waitpid()函数来获取子进程的退出状态。这个过程被称为“收割”子进程。一旦父进程“收割”了子进程,子进程就不再是僵尸进程,它在进程表中的表项也会被释放。
如果父进程没有收割子进程,子进程就会一直保持为僵尸状态。这通常是因为编程错误,因为正常情况下父进程都应该收割子进程。
如果父进程先于子进程结束,子进程将被init进程接管,init进程会负责收割子进程,防止其成为僵尸进程。所以,不是所有的进程都会变成僵尸进程。


僵尸进程的代码是?如何在命令行查看进程状态

在 Unix 或 Linux 系统中,僵尸进程的状态是 “Z”(代表 Zombie)。

你可以在命令行使用 ps 命令查看进程状态,例如:
ps -l

在这个命令的输出中,STIME列显示了进程的状态。如果你看到 “Z”,那就表示这个进程是一个僵尸进程。
需要注意的是,僵尸进程虽然在进程表中占据了一个位置,但是它们已经释放了所有的资源,包括内存和文件描述符,所以它们不会消耗系统资源。然而,如果系统中有大量的僵尸进程,那么进程表可能会被填满,这会阻止新的进程创建。因此,我们通常都会避免出现僵尸进程,或者在它们出现后立即对它们进行处理。


产生僵尸进程的原因是:?

僵尸进程主要是由于父进程未能正确地收集其子进程的退出状态信息而产生的。
在 UNIX 和 Linux 系统中,当一个子进程结束运行时,它并不会立即消失,而是变成了一个僵尸进程。这是因为它的父进程可能还需要获取它的退出状态信息。子进程的退出状态信息包括了它是如何结束的(例如是正常退出还是因为接收到了某个信号而结束),以及它的返回值。
父进程通过调用 wait() 或 waitpid() 函数来获取这个信息。当父进程调用了这些函数后,子进程就会消失,释放它在进程表中的表项。
然而,如果父进程没有调用 wait() 或 waitpid(),那么子进程就会一直保持为僵尸状态。这是因为系统无法确定父进程是否还需要子进程的退出状态信息,所以它必须保留子进程的一些基本信息,直到父进程明确表示它不再需要这些信息为止。
因此,僵尸进程的产生主要是因为父进程没有正确地处理子进程的退出。这通常是因为编程错误,因为正常情况下,父进程都应该收集其子进程的退出状态信息。(并且是主动收集,操作系统可没有这个义务)

下面是个例子:

#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);
    }
    else {
    
    
        /* 这是子进程,立即结束,这将使它变成僵尸进程 */
        exit(0);
    }

    return 0;
}

在这个例子中,当子进程结束后,它会变成一个僵尸进程,因为父进程没有收集它的退出状态信息。父进程在睡眠60秒后才结束,所以你有足够的时间在另一个终端窗口使用ps命令查看到这个僵尸进程。
注意:不要在你的实际程序中创建僵尸进程,这只是一个演示僵尸进程产生原因的例子。在实际编程中,你应该总是确保父进程收集了其子进程的退出状态信息,防止出现僵尸进程。


进程的销毁是操作系统在进程完成其生命周期或需要被终止时的一个过程。以下是操作系统销毁进程的一般步骤:

通知接收资源的进程:如果销毁的进程是其他进程的父进程,并且子进程已经结束,那么操作系统需要通知父进程以便回收资源。

释放进程资源:操作系统会释放进程所使用的所有系统资源,包括内存、打开的文件、I/O缓冲区等。

删除进程控制块(Process Control Block, PCB):进程控制块是系统中存储进程信息的数据结构,销毁进程时需要将其删除。

这个过程是简化的描述,实际上,操作系统在销毁进程时可能需要执行更多步骤,例如,处理权限、安全问题等。具体的销毁过程也可能因操作系统的不同而有所差异。
注意:进程的销毁并不总是由进程自身完成的。有时,操作系统也会因为某些原因(如资源需求超过系统可提供的最大限制、执行了非法操作等)强制结束进程。这通常被称为进程的“杀死”或“终止”。


怎么销毁僵尸进程呢:
(1)使用wait()销毁僵尸进程

wait()函数是Unix和Linux环境下的系统调用,用于使父进程暂停执行,直到它的一个子进程结束为止。当一个子进程结束后,wait()函数也会回收结束的子进程的资源,防止它变成僵尸进程。
wait()函数的原型如下:

#include <sys/wait.h>
pid_t wait(int *status);

参数:

status:一个指向int类型的指针,用于保存子进程的退出状态信息。如果你对这个信息不感兴趣,你可以传递一个NULL指针。

返回值:

如果函数成功,返回被收集的子进程的进程ID。

如果函数调用失败,返回-1。

如果调用进程没有子进程,函数也会返回-1,并将errno设置为ECHILD。

这是一个简单的C语言程序,它创建了一个子进程并使用 wait() 函数来获取子进程的退出状态:

#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

int main() {
    
    
    pid_t child_pid;
    int status;

    /* 创建子进程 */
    child_pid = fork();

    if (child_pid > 0) {
    
    
        /* 这是父进程,等待子进程结束 */
        wait(&status);
    }
    else {
    
    
        /* 这是子进程,立即结束 */
        exit(0);
    }

    return 0;
}

在这个例子中,wait() 函数会阻塞父进程,直到一个子进程结束。它的参数是一个整数指针,用于存储子进程的退出状态。当子进程结束时,wait() 函数就会返回,父进程可以通过该指针获取子进程的退出状态。
wait() 和 waitpid() 的主要区别在于它们等待的是哪个子进程。wait() 函数会等待任何一个子进程结束,而 waitpid() 可以指定要等待的子进程的 PID。如果你有多个子进程,而你只关心某个特定的子进程,那么你就需要使用 waitpid() 函数。此外,waitpid() 还有一些额外的选项,比如可以选择不阻塞父进程等。wait()要谨慎使用!

(2)使用waitpid()销毁僵尸进程
waitpid()函数在Unix和类Unix操作系统中用来使父进程暂停执行,直到它的子进程结束为止。它的函数原型是:

#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *status, int options);

这个函数的参数分别为:

pid: 这个参数用来指定要等待的子进程的进程ID(PID)。它有几种可能的值:

如果 pid > 0,那么 waitpid() 会等待与 pid 相等的进程。

如果 pid == -1,那么 waitpid() 的行为就和 wait() 一样,会等待任何一个子进程。

如果 pid == 0,那么 waitpid() 会等待和当前进程在同一个进程组的任何进程。

如果 pid < -1,那么 waitpid() 会等待进程组ID等于 pid 绝对值的任何进程。

status: 这是一个指向 int 类型变量的指针,用于存储子进程的退出状态。可以通过宏 WIFEXITED(status), WEXITSTATUS(status)等来检查子进程是如何结束的,以及获取其返回值。

options: 这个参数用于修改 waitpid() 的行为。例如,可以设置 WNOHANG 选项使 waitpid() 在子进程还在运行时不阻塞父进程。也可以设置 WUNTRACED 选项使 waitpid() 能够返回停止的子进程的信息。

waitpid() 函数返回等待结束的子进程的PID,如果设置了 WNOHANG 选项并且没有子进程已经结束,那么它会立即返回0。如果出错,它会返回-1。
这是一个使用 waitpid() 函数的C语言程序的例子。该程序创建了一个子进程并使用 waitpid() 函数来等待这个特定的子进程结束。

#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdlib.h>

int main() {
    
    
    pid_t child_pid;
    int status;

    /* 创建子进程 */
    child_pid = fork();

    if (child_pid > 0) {
    
    
        /* 这是父进程,等待特定的子进程结束 */
        waitpid(child_pid, &status, 0);
    }
    else {
    
    
        /* 这是子进程,立即结束 */
        exit(0);
    }

    return 0;
}

在这个例子中,waitpid() 函数等待的是具有特定 PID 的子进程,而不是任意子进程。这个特定的 PID 是 fork() 函数返回的 PID,即新创建的子进程的 PID。因此,waitpid() 函数让你可以控制你要等待哪个子进程,而 wait() 函数则会等待任何一个子进程结束。
另外,waitpid() 的第三个参数是一个选项参数,用于改变 waitpid() 的行为。在这个例子中,我们传入了 0,这表示我们想要 waitpid() 的默认行为,即阻塞父进程,直到指定的子进程结束。

waitpid() 函数的第三个参数是一个选项集,可以用来改变 waitpid() 的行为这些选项可以通过位运算符(|)组合使用。以下是一些常见的选项:

WNOHANG:如果设置了这个选项,那么在没有子进程结束的情况下,waitpid() 函数不会阻塞父进程。如果没有子进程已经结束,它会立即返回0。

WUNTRACED:如果设置了这个选项,那么即使子进程被暂停,waitpid() 函数也会返回。在返回的状态参数中可以使用 WIFSTOPPED(status) 宏来检查子进程是否被暂停,使用 WSTOPSIG(status) 宏来获取使子进程暂停的信号。

WCONTINUED:如果设置了这个选项,那么如果子进程在被暂停后又被继续执行,waitpid() 函数也会返回。在返回的状态参数中可以使用 WIFCONTINUED(status) 宏来检查子进程是否在被暂停后继续执行。

注意,以上的这些行为可能会受到系统的支持和限制,不是所有的Unix和类Unix系统都支持所有的选项。在使用时,最好查阅具体的系统文档或者使用 man waitpid 命令来获取更详细的信息。


kill杀死进程:
在 Unix 和类 Unix 系统(例如 Linux)中,可以使用 kill 命令或者 kill() 系统调用来发送一个信号给一个进程。当你想要“杀死”一个进程时,通常发送的是 SIGTERM 或者 SIGKILL 信号。

SIGTERM 是默认的、最常用的信号,它会请求进程终止。这个信号可以被进程捕获,这样进程就可以在结束前进行清理操作,例如保存数据、释放资源等。

SIGKILL 是一个更强硬的信号,它会立即结束进程。这个信号不能被进程捕获,因此它总是会结束进程,但是进程可能无法正确地进行清理操作。

在命令行中,你可以使用 kill 命令加上进程 ID 来发送信号。例如,下面的命令会发送 SIGTERM 信号给进程 12345:
kill 12345

如果 SIGTERM 信号没有效果,你可以使用 -9 选项来发送 SIGKILL 信号:
kill -9 12345

在 C 程序中,你可以使用 kill() 系统调用来发送信号。例如,下面的代码会发送 SIGTERM 信号给进程 12345:
#include <signal.h>
#include <sys/types.h>

kill(12345, SIGTERM);

如果 SIGTERM 信号没有效果,你可以发送 SIGKILL 信号:
kill(12345, SIGKILL);

请注意,你需要有足够的权限才能发送信号给一个进程。如果你是进程的所有者,或者你是 root 用户,那么你就可以发送信号给进程。否则,你可能需要使用 sudo 或者其他方式来获取足够的权限。


信号处理结束消灭僵尸进程:

僵尸进程是指已经完成执行但尚未被父进程回收资源的子进程。如果父进程没有正确地处理这些已经结束的子进程,它们就会保留在进程表中,消耗系统资源,这是不好的。
要避免僵尸进程,通常的做法是父进程需要调用 wait() 或 waitpid() 函数来回收子进程的资源。但这种方法有一个问题,即父进程必须阻塞等待子进程结束,这在父进程还有其他任务要做的情况下是不合适的。
为此,我们可以使用信号处理机制来解决这个问题。我们可以捕捉 SIGCHLD 信号,当子进程结束时,就会向父进程发送这个信号。父进程可以在 SIGCHLD 的处理函数中回收子进程的资源,从而避免僵尸进程。以下是一个例子:

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

void sigchld_handler(int sig) {
    
    
    // 注意:在信号处理函数中调用 wait() 或 waitpid() 函数时,需要使用 WNOHANG 选项
    // 这样如果没有子进程结束,这些函数就不会阻塞
    while (waitpid(-1, 0, WNOHANG) > 0);
}

int main() {
    
    
    signal(SIGCHLD, sigchld_handler);

    // 创建子进程
    pid_t pid = fork();
    if (pid == 0) {
    
    
        // 子进程
        printf("Hello from child\n");
        sleep(10); // 让子进程睡眠 10 秒钟来模拟长时间运行
        exit(0);
    } else if (pid > 0) {
    
    
        // 父进程
        printf("Hello from parent\n");
        while (1) {
    
    
            // 父进程继续执行其他任务...
            sleep(1);
        }
    } else {
    
    
        // fork 失败
        perror("fork");
        exit(1);
    }

    return 0;
}

在这个程序中,我们首先设置了 SIGCHLD 信号的处理函数为 sigchld_handler()。然后我们创建了一个子进程,并让子进程睡眠 10 秒钟来模拟长时间运行的任务。父进程则继续执行其他任务。当子进程结束时,就会发送 SIGCHLD 信号,父进程就会调用 sigchld_handler() 函数,在这个函数中调用 waitpid() 函数来回收子进程的资源。

1.1.4、进程的状态

进程状态模型是一个描述进程在其生命周期中可能经历的各种状态以及状态转换规则的模型。在大多数操作系统中,进程通常有以下三种基本状态:

运行状态(Running):进程正在CPU上执行或已被调度并等待执行。

就绪状态(Ready):进程已经准备好运行,但由于其他进程正在运行而在就绪队列中等待。

阻塞状态(Blocked):进程正在等待某种事件(例如,等待I/O操作完成)或资源(例如,等待获取锁)。在此状态下,即使CPU空闲,进程也无法执行。


有些操作系统的进程状态模型可能更为复杂,包含更多的状态。例如:

新建状态(New):进程刚刚被创建,但还未被加入到就绪队列中。

终止状态(Terminated):进程已经完成或被终止,但操作系统还没有删除其进程控制块(Process Control Block)。

挂起状态(Suspended):进程被挂起并移到磁盘以释放更多内存。被挂起的进程不能执行,但在某些情况下可以被操作系统重新激活并恢复到内存中。

进程的状态会在其生命周期中根据CPU调度、事件发生等条件进行转换。例如,当CPU从一个进程切换到另一个进程时,当前进程从运行状态变为就绪状态,而被调度的进程从就绪状态变为运行状态。同样,当进程开始等待I/O操作时,它会从运行状态变为阻塞状态。

操作系统中的内存和硬盘很重要

1.1.6、守护进程

守护进程(Daemon)是在Unix和类Unix系统(如Linux)中运行的一种特殊的后台进程。它们通常在系统引导(启动)时开始运行,并在系统关闭时结束。守护进程不和终端关联,并且通常用于执行后台服务,例如HTTP服务器、数据库服务器等。
这些进程通常独立于用户会话运行,也就是说,当用户登录和注销系统时,守护进程会继续运行。由于它们不与任何用户终端关联,所以不会有任何用户输入,也不会直接显示任何输出。
创建守护进程通常涉及到以下步骤:

创建新的子进程并结束父进程,使得子进程成为新的进程组的领头进程,这样可以使得进程摆脱原会话和控制终端。

在子进程中创建新的会话,子进程成为新会话的领头进程和新进程组的领头进程。

改变当前的工作目录为根目录。

重设文件权限掩码。

关闭所有的打开文件描述符。

重定向标准输入、标准输出和标准错误到/dev/null。

1.2、线程

1.2.1、线程概念

线程是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运行单位。一个进程可以创建和撤销线程,也可以包含多个线程。
线程有以下的特性:

线程是轻量级的,创建线程比创建进程要快,线程之间切换开销比进程之间切换要小。

线程共享进程的资源。每个线程都有自己的堆栈和寄存器,但线程会共享进程的代码段,数据段和其他操作系统资源,如打开文件和信号。

线程有自己的程序计数器,堆栈和寄存器。每个线程都有自己的执行路径和当前状态。

线程可以是单线程或多线程的:

单线程:在给定时间内,CPU只能执行一个线程的任务。

多线程:在给定时间内,CPU可以执行多个线程的任务。

多线程可以更有效地使用系统资源,提高系统的吞吐率,但也会增加系统的复杂性,因为需要管理和同步多个线程。

1.2.2、线程建立

在 C 语言中,你可以使用 POSIX 线程(也称为 pthread)库来创建和管理线程。下面是一个简单的例子,它创建了一个新的线程,新线程运行的函数是 print_hello。

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

// 这是新线程将运行的函数
void* print_hello(void* data) {
    
    
    printf("Hello from new thread!\n");
    pthread_exit(NULL); // 退出线程
}

int main() {
    
    
    pthread_t thread_id; // 用来存储新线程的 ID

    // 创建新线程,新线程将运行 print_hello 函数
    int result = pthread_create(&thread_id, NULL, print_hello, NULL);
    if (result != 0) {
    
    
        printf("Error: unable to create thread.\n");
        exit(-1);
    }

    printf("Hello from main thread!\n");

    // 等待新线程结束
    pthread_join(thread_id, NULL);

    return 0;
}

在这个程序中,我们首先调用 pthread_create 函数来创建一个新的线程。我们传递给这个函数的参数分别是一个指向线程 ID 的指针,一个线程属性对象(我们不需要特殊的属性,所以传入 NULL),新线程将要运行的函数的指针,以及传递给这个函数的参数(我们没有参数,所以传入 NULL)。
然后主线程打印出一条消息,然后调用 pthread_join 函数等待新线程结束。
新线程运行 print_hello 函数,在这个函数中,它打印出一条消息,然后调用 pthread_exit 函数结束自己。

1.2.3、线程销毁

在 C 语言中,一个线程可以通过调用 pthread_exit() 函数来结束自身。这个函数接收一个 void* 类型的参数,这个参数的值可以被其他线程通过 pthread_join() 函数获取。

序是:pthread_create->pthread_exit()->pthread_join()

除此之外,还有pthread_detach() 也可以用于线程资源回收:

pthread_detach() 和 pthread_join() 都是用于处理结束线程的资源回收的,但它们的工作方式有所不同:

pthread_join() 是一个阻塞的函数,它让当前线程等待直到指定的线程结束。当 pthread_join() 返回时,被等待线程的资源被自动回收。此外,你还可以通过 pthread_join() 的第二个参数来获取线程的返回值。

pthread_detach() 会立即返回,并且不会阻塞当前线程。它将线程状态设置为“分离”状态,这意味着当线程结束时,它的资源会自动回收,你不能再使用 pthread_join() 来等待它。

所以,你应该根据具体的需求来选择使用哪个函数。如果你需要知道线程何时结束,并可能需要获取它的返回值,你应该使用 pthread_join()。如果你不需要关心线程何时结束,你可以使用 pthread_detach(),让线程在结束时自动清理自己的资源。
注意:如果一个线程既没有被 join,也没有被 detach,那么当它结束时,它的状态将变为"僵尸",这可能会导致资源泄露。因此,通常你需要确保每个线程要么被 join,要么被 detach。

1.3、进程间通信

插一嘴 进程间通信的问题同时适用于线程间通信的问题!

1.3.1、概念

进程间通信(Interprocess Communication,IPC)是指在操作系统中,一个进程与另一个进程传输或交换数据的过程。IPC 是必要的,因为现代系统通常允许同时运行多个进程,并且这些进程可能需要共享信息。常见的进程间通信方法包括:

管道(Pipe):这是最简单的IPC形式,数据从一个进程流向另一个进程。管道主要用于父子进程之间的通信。

命名管道(Named Pipes):它也被称为FIFO,是管道的一个扩展,可以允许无关进程间的通信。

信号(Signals):信号是一种通知机制,用于通知一个进程某个事件已经发生。例如,一个进程可以向另一个进程发送一个信号,要求其终止执行。

消息队列(Message Queues):消息队列允许一个进程向另一个进程发送格式化的消息。这些消息被放在一个队列中,接收进程可以按照顺序读取。

共享内存(Shared Memory):在此模型中,两个或多个进程可以访问同一块内存区域。这是一种非常快速的通信方法,但需要进程自行处理同步和数据一致性问题。

套接字(Sockets):套接字可以用于不同机器之间的进程通信(也可用于同一机器的进程通信)。它是Internet上最常用的通信机制。

信号量(Semaphores):虽然信号量主要用于同步,但它们也可以用于进程间通信。

每种IPC方法都有其适用场景和优缺点,需要根据实际的应用需求来选择合适的通信方法。

1.3.2、使用案例

管道是 Unix 和 Linux 系统提供的一种进程间通信(IPC)方式。下面是一个简单的 C 语言示例,其中父进程创建一个管道,并通过管道向子进程发送一个字符串。

#include <stdio.h>
#include <string.h>
#include <unistd.h>

#define BUFFER_SIZE 25
#define READ_END 0
#define WRITE_END 1

int main(void) {
    
    
    char write_msg[BUFFER_SIZE] = "Hello, World!";
    char read_msg[BUFFER_SIZE];
    int fd[2];

    if (pipe(fd) == -1) {
    
    
        fprintf(stderr, "Pipe failed");
        return 1;
    }

    pid_t pid = fork();

    if (pid < 0) {
    
    
        fprintf(stderr, "Fork failed");
        return 1;
    }

    if (pid > 0) {
    
      /* parent process */
        close(fd[READ_END]);
        write(fd[WRITE_END], write_msg, strlen(write_msg)+1);
        close(fd[WRITE_END]);
    } else {
    
      /* child process */
        close(fd[WRITE_END]);
        read(fd[READ_END], read_msg, BUFFER_SIZE);
        printf("Child reads: %s\n", read_msg);
        close(fd[READ_END]);
    }

    return 0;
}

消息队列(Message Queue)的 C 语言使用案例:
消息队列也是一种进程间通信方式,通常用于发送和接收复杂的数据。下面是一个简单的示例,其中创建了一个消息队列,并向其中发送和接收消息。

#include <stdio.h>
#include <string.h>
#include <sys/ipc.h>
#include <sys/msg.h>

struct msg_buffer {
    
    
    long msg_type;
    char text[100];
} message;

int main() {
    
    
    key_t key;
    int msg_id;

    key = ftok("progfile", 65);  // create unique key
    msg_id = msgget(key, 0666 | IPC_CREAT);  // create message queue

    message.msg_type = 1;

    printf("Write data: ");
    fgets(message.text, 100, stdin);

    // send message
    msgsnd(msg_id, &message, sizeof(message), 0);

    // display sent message
    printf("Data sent: %s\n", message.text);

    return 0;
}

1.4、多线程,线程同步

1.4.1、并发

并发(Concurrency)是指两个或更多的任务在逻辑上同时发生。在给定的时间内,这些任务可能会交替运行(在单核处理器上)或者真正同时运行(在多核处理器上)。
在操作系统中,当我们谈到并发时,我们通常是在谈论进程或线程的并发 这意味着多个进程或线程在同一时间段内被执行,但并不意味着它们在同一时刻被执行。例如,在单核处理器中,CPU会通过在不同的进程或线程之间迅速切换来创建并发的错觉。然而,在多核或多处理器的系统中,可以同时执行多个进程或线程,从而实现真正的并发。
并发能够提高系统的效率和响应性。通过并发,用户可以同时运行多个应用程序,或者一个应用程序可以分割为多个独立的子任务,这些子任务可以在不同的处理器或核心上同时运行,从而提高应用程序的性能。
然而,并发也带来了一些挑战,如需要处理数据的一致性问题(如竞争条件)以及需要进行适当的进程或线程调度以实现公平性和有效性等。


并发和并行的区别:
"并发"和"并行"都是关于在同一时间段执行多个任务的概念,但它们的含义和使用场景略有不同:

并发(Concurrency):并发是指在同一时间段内执行多个任务。并发并不意味着这些任务在同一时刻执行,而是在一段时间内它们都有运行。这可以在单核处理器中实现,通过时间片轮转或其他调度技术,让处理器在不同任务之间快速切换,给人一种同时执行的错觉。

并行(Parallelism):并行是指在同一时刻执行多个任务,这需要多核或多处理器。在并行计算中,多个任务在同一时间点真正同时进行。

简单地说,当你有一个可以同时运行多个任务的系统时,你就可以进行并行处理。如果你有一个系统,它在同一时间段内可以处理多个任务,但不一定在同一时间点执行,那么你就在进行并发处理。

1.4.2、竞争条件

竞争条件(Race Condition)是指在并发环境下,两个或多个进程对共享数据进行读写操作,并且最后的结果取决于这些进程执行的精确顺序如果这些进程的执行顺序会影响最终的结果,那么就可能发生竞争条件。
举个例子,假设有两个进程,一个用于在账户余额上增加100元(进程A),另一个用于从账户余额中扣除50元(进程B)。在没有竞争条件的情况下,这两个进程的执行顺序无关紧要。但是,如果这两个进程的执行顺序如下:进程A读取账户余额,然后进程B读取账户余额,进程B减去50元,然后进程A增加100元,最后进程B和进程A分别写回新的账户余额。在这种情况下,最后的账户余额将只增加50元,而不是预期的增加50元并再增加100元,这就是竞争条件。
为了避免竞争条件,可以使用各种同步机制,如互斥锁(Mutexes)、信号量(Semaphores)、监视器(Monitors)等来保证对共享资源的访问在任何时候只被一个进程进行,从而确保共享数据的一致性

1.4.3、线程同步——互斥

互斥(Mutual Exclusion)是一种同步机制,用于防止两个或更多的进程或线程同时访问某个共享资源(如变量、数组、文件等)。在任何给定时间,只能有一个线程或进程访问受互斥保护的资源。
互斥常常通过使用一种叫做“互斥锁”(或简称“锁”)的工具实现。一个线程在访问共享资源之前必须先获得锁,如果锁已被其他线程持有,那么该线程将被阻塞,直到锁被释放。获取锁之后,线程可以自由访问共享资源,并在完成后释放锁,以供其他线程使用。
临界区(Critical Section)是在并发编程中,可以访问共享资源的一段代码区域。在这个区域中,代码的执行需要互斥,也就是说,一次只能有一个线程或进程执行这段代码,以防止产生竞争条件。
例如,在多线程程序中,如果多个线程需要修改同一个变量,那么这个变量的读取和修改的操作就构成了一个临界区。这个临界区需要通过互斥锁来保护,以防止多个线程同时修改这个变量,导致数据不一致。

案例:

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

// 创建全局变量
int count = 0;

// 创建互斥锁
pthread_mutex_t count_mutex;

void* increment(void* arg) {
    
    
    int i;
    for(i = 0; i < 1000000; i++) {
    
    
        // 获取互斥锁
        pthread_mutex_lock(&count_mutex);
        count++;
        // 释放互斥锁
        pthread_mutex_unlock(&count_mutex);
    }
    return NULL;
}

int main() {
    
    
    pthread_t thread1, thread2;

    // 初始化互斥锁
    pthread_mutex_init(&count_mutex, NULL);

    // 创建两个线程
    pthread_create(&thread1, NULL, increment, NULL);
    pthread_create(&thread2, NULL, increment, NULL);

    // 等待两个线程都完成
    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);

    printf("Final count value: %d\n", count);

    // 销毁互斥锁
    pthread_mutex_destroy(&count_mutex);

    return 0;
}

临界区的大小没有官方的界定,需要自己的编程习惯判断。

1.4.4、线程同步——信号量

在 C 语言中,可以使用 POSIX 提供的 sem_wait 和 sem_post 函数来实现线程同步。下面是一个例子,该程序创建了两个线程,它们都试图对同一份数据进行修改。为了防止数据竞争,我们使用一个信号量来同步这两个线程的操作:

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

int shared_data = 0;
sem_t sem;

void* increment(void* arg) {
    
    
    for (int i = 0; i < 100000; ++i) {
    
    
        sem_wait(&sem);
        ++shared_data;
        sem_post(&sem);
    }
    return NULL;
}

void* decrement(void* arg) {
    
    
    for (int i = 0; i < 100000; ++i) {
    
    
        sem_wait(&sem);
        --shared_data;
        sem_post(&sem);
    }
    return NULL;
}

int main() {
    
    
    pthread_t thread1, thread2;

    sem_init(&sem, 0, 1); // 初始化信号量

    pthread_create(&thread1, NULL, increment, NULL);
    pthread_create(&thread2, NULL, decrement, NULL);

    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);

    printf("Shared data: %d\n", shared_data);

    sem_destroy(&sem); // 销毁信号量

    return 0;
}

在这个例子中,sem_wait(&sem) 将阻塞,直到信号量的值大于 0,然后将信号量的值减 1。sem_post(&sem) 将增加信号量的值,如果有其他线程正在等待信号量,那么其中一个将解除阻塞状态。
这样,我们就能确保 shared_data 不会同时被两个线程修改,从而避免了数据竞争的问题。

1.5、进程调度

进程调度是操作系统中的一个核心功能,其主要目标是有效地管理和调度进程在处理器上的执行,从而达到高效、公平和安全的运行。
具体地说,操作系统在多个可运行的进程之间做出决策,决定哪个进程应当获得处理器资源,这个决策过程就叫做进程调度。它的主要目标是尽可能多地运行进程,使处理器使用率最大化,同时也保证了系统的公平性和响应时间。
进程调度有几种主要的策略,如先来先服务(FCFS),最短作业优先(SJF),优先级调度,时间片轮转(RR)等等,它们各自有其优缺点,并适用于不同的应用场景。

先来先服务 (FCFS):这是最简单的调度策略。在这种策略下,进程按照他们到达 CPU 的顺序进行服务。首先到达的进程首先被服务。这种策略很公平,但如果长作业排在前面,可能会导致短作业等待时间过长,这就是所谓的"饥饿"问题。

最短作业优先 (SJF):在这种策略下,处理器会选择执行时间最短的进程。这种策略可以最小化平均等待时间,但问题在于需要知道进程的执行时间,这在实际操作中往往很难预知。

优先级调度:在这种策略下,每个进程都有一个优先级,处理器会优先服务优先级高的进程。这种策略很灵活,但需要合理的设置优先级,否则可能导致低优先级的进程饥饿。

时间片轮转 (RR):在这种策略下,每个进程都被分配一个固定长度的时间片或量子,进行执行。如果进程在时间片结束时还没有完成,那么它就会被移回队列的尾部,然后处理器会转而服务下一个进程。这种策略可以防止长作业挤占 CPU,从而实现公平调度。

这些策略各有优缺点,适用于不同的场景。在实际的操作系统中,往往会使用复杂的调度策略,或者同时使用多种策略,以达到最优的系统性能和用户体验。

总结

持续更新中…

猜你喜欢

转载自blog.csdn.net/weixin_46274756/article/details/131153009