大部分操作系统的任务调度是采用时间片轮转的抢占式调度方式,也就是说一个任务执行一小段时间后强制暂停去执行下一个任务,每个任务轮流执行。任务执行的一小段时间叫做时间片,任务正在执行时的状态叫运行状态,任务执行一段时间后强制暂停去执行下一个任务,被暂停的任务就处于就绪状态等待下一个属于它的时间片的到来。这样每个任务都能得到执行,由于CPU的执行效率非常高,时间片非常短,在各个任务之间快速地切换,给人的感觉就是多个任务在“同时进行”,这也就是我们所说的并发(一个时间间隔内,多个任务同时进行)。
一、进程与线程
1.1 进程
CPU是计算机的核心,承担所有计算任务;
操作系统是计算机管理者,负责任务的调度、资源的分配和管理,统领整个计算机硬件;
应用程序是具有某种功能的程序,程序是运行于操作系统之上的;
进程是一个具有一定独立功能的程序在一个数据集上的一次动态执行的过程,是操作系统进行资源分配和调度的一个独立单位,是应用程序运行的载体。
进程一般由程序、数据集合和进程控制块三部分组成:
- 程序用于描述进程要完成的功能,是控制进程执行的指令集;
- 数据集合是程序在执行时所需要的数据和工作区;
- 程序控制块(Program Control Block,简称PCB),包含进程的描述信息和控制信息,是进程存在的唯一标志。
进程的特征:
- 动态性:进程是程序的一次执行过程,是临时的,有生命期的,是动态产生,动态消亡的;
- 并发性:任何进程都可以同其他进程一起并发执行;
- 独立性:进程是系统进行资源分配和调度的一个独立单位;
- 结构性:进程由程序、数据和进程控制块三部分组成。
1.2 线程
早期的操作系统中并没有线程的概念,进程是能拥有资源和独立运行的最小单位,也是程序执行的最小单位。任务调度采用的是时间片轮转的抢占式调度方式,而进程是任务调度的最小单位,每个进程有各自独立的一块内存,使得各个进程之间内存地址相互隔离。
后来,随着计算机的发展,对CPU的要求越来越高,进程之间的切换开销较大,已经无法满足越来越复杂的程序的要求了。于是就发明了线程,线程是程序执行中一个单一的顺序控制流程,是程序执行流的最小单元,是处理器调度和分派的基本单位.一个进程可以有一个或多个线程,各个线程之间共享程序的内存空间(也就是所在进程的内存空间)。一个标准的线程由线程ID、当前指令指针(PC)、寄存器和堆栈组成。而进程由内存空间(代码、数据、进程空间、打开的文件)和一个或多个线程组成。
进程与线程的区别:
- 线程是程序执行的最小单位,而进程是操作系统分配资源的最小单位;
- 一个进程由一个或多个线程组成,线程是一个进程中代码的不同执行路线;
- 进程之间相互独立,但同一进程下的各个线程之间共享程序的内存空间(包括代码段、数据集、堆等)及一些进程级的资源(如打开文件和信号),某进程内的线程在其它进程不可见;
- 调度和切换:线程上下文切换比进程上下文切换要快得多。
1.3 多任务的两种表现形式
并发:
在一段时间内交替执行多个任务;
例如:在单核cpu处理多任务,操作系统轮流让各个任务交替执行。
并行:
在一段时间内真正同时执行多个任务;
例如:对于多核cpu处理多任务,操作系统会给cpu的每个内核安排一个执行的任务,多个内核是真正的一起同时执行多个任务。这里需要注意多核cpu是并行的执行多任务,始终有多个任务一起执行。
二、多进程
2.1 多进程介绍
假设有一段代码:func a函数执行完毕后才能执行func b函数;如果可以让func a和func b同时运行,程序的效率会大大提升。
2.2 fork父子进程
fork() 函数用于创建一个新的进程,该进程称为子进程。原有的进程称为父进程。子进程是父进程的副本,但是它们有不同的进程ID(PID)。
返回值:
- 在父进程中,fork() 返回子进程的PID;
- 在子进程中,fork() 返回0;
- 如果出现错误,fork() 返回一个负值。
在实际开发中,有一个严格的规定:子进程只能在子进程工作区执行代码,不允许踏出自己的工作区。执行完自己的代码块通过exit()结束进程。
init进程:默认情况下所有进程都有父进程,除了init进程;init是系统启动初始化首个服务进程,所有系统下的进程都是它的子集。
fork函数调用之后,分为多个过程:
-
先调用_CREATE函数,创建子进程,但是这个子进程的内存空间是空的,所以接下来调用_CLONE函数;
-
_CLONE函数将父进程的所有内存空间(包括代码段、数据段、堆、栈等)、寄存器状态、文件描述符等克隆给子进程;
-
执行完克隆操作后,会有第一个返回值,返回给父进程创建的子进程的PID;
-
之后的操作由子进程进行,数据清理、回收等工作。等所有操作完成后,函数返回0,这也就是为什么fork函数针对父子进程有不同的返回值;
-
父子进程可以共享fork函数栈帧,一人执行函数的一部分,得到两个不同的返回值,便于区分父子进程任务。
多进程创建的模版:通过循环调用fork() 创建多进程(一父多子)。
查看代码
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/fcntl.h>
#include <pthread.h>
#include <signal.h>int main()
{pid_t pid;int i;for (i = 0; i < 5; ++i){pid = fork();if (pid == 0)break; // 每次创建完一个子进程后,我们让它退出循环}if (pid > 0){ // 父进程工作区printf("Parent PID: %d\n",i);}else if (pid == 0) // 子进程工作区{if (i == 0){printf("Child PID: %d 游泳\n", i);while(1); }else if(i==1){printf("Child PID: %d 跑步\n", i);while(1);}printf("Child PID: %d\n", i);exit(0); // 子进程执行完工作区代码后必须结束进程}else{perror("fork call failed");exit(0);}return 0;
}
通过循环创建多个子进程。每次调用 fork() 后,会得到不同的PID。
根据PID来区分是自己还是子进程,并输出相应的信息。
第三版fork:
第三版fork(现在所使用的fork)会为子进程的0-3G内存会申请一块映射内存,映射父进程的用户层数据,来进行读取访问父进程的用户空间(读时共享)。在需要修改数据时,会将父进程的数据复制到子进程,对复制的数据进行修改(写时复制)。
写复制机制对父子进程都有效,父进程修改映射数据也要进行复制操作。
关于多次执行fork,进程创建数量:
有一个父进程,连续调用n次fork函数,那么这个过程一共创建了多少子进程?
有一个父进程,连续调用n次fork函数,那么这个过程一共创建了多少子进程?
答:2n-1个。创建出来的子进程执行当前fork后的函数体代码。
2.3 exec函数族
它并不只是一个函数,而是以exec开头的六个函数,并且是没有exec这个函数的。
- exec函数族是一种不同于fork函数创建子进程的方式。它的本意不是创建新的进程,而是用exec指定的程序的虚拟地址空间去替换执行这个函数所在进程的虚拟地址空间。主要是用户区的替换。但是子进程的pid是不会变的。
- exec函数族的函数主要是执行指定的可执行文件或者是shell命令。
- 该函数用法:首先在main的主进程中用fork函数创建一个子进程,然后在这个子进程中调用exec函数族的函数。当执行exec函数族的函数之后,当前这个子进程的进程映像就被exec指定的执行程序替换掉了。所以exec函数之后的代码都不会执行。
- 返回值介绍:函数族中的函数在执行成功的时候都不会有返回值。如果失败就会返回-1,并且设置对应errno值。
最常用就是两个函数,execl和execlp。
(1)execl:
#include <unistd.h>
#include <stdio.h>
int main()
{//创建一个子进程pid_t pid = fork();if (pid > 0){//父进程printf("这是一个父进程 --> %d\n", pid);}else if (pid == 0){//子进程//执行可执行文件// int ret = execl("a", "a", "Hello", "World", NULL); // execl函数//执行shell命令int ret = execl("/usr/bin/ps", "ps", "-aux", NULL);if (ret < -1){perror("execl");}//这句代码不会被执行,因为execl如果执行成功,那么这个子进程的虚拟地址空间的用户区就会被上面的a可执行文件替换掉//当前这个子进程的数据就被清理了,只会执行可执行文件的代码printf("子进程 ---");}
}
总结:execl函数是通过可变长参数指定可执行参数的,第一个参数需要指定可执行文件或者shell命令的路径,推荐绝对路径。
(2)execlp:
会到环境变量中查找指定的可执行文件,如果找到了就执行,找不到就执行不成功。
查看代码#include <unistd.h>
#include <stdio.h>
int main()
{//创建一个子进程pid_t pid = fork();if (pid > 0){//父进程printf("这是一个父进程 --> %d\n", pid);}else if (pid == 0){//子进程//执行可执行文件// int ret = execlp("a", "a", "Hello", "World", NULL); // execlp函数,a可执行文件不在PATH环境变量中是执行不成功的。int ret = execlp("ps", "ps", "-aux", NULL);if (ret < -1){perror("execl");}}
}
2.4 孤儿进程、僵尸进程和进程回收
(1)孤儿进程
父进程死亡,子进程还在运行的进程就叫孤儿进程。
孤儿进程一般没有危害,因为当父进程死亡时,Linux内核会让init(pid=1)来暂时充当孤儿进程的父进程,init进程会循环的wait()它的子进程去释放自己的资源。回收它的内核区资源。
方案:父进程可以通过wait 或者waitpid函数来等待子进程结束,从而回收它的资源。
(2)僵尸进程
每个进程结束之后,都会释放自己地址空间中的用户区数据,内核区的PCB没有办法自己释放掉,需要父进程去释放;
进程终止时,父进程尚未回收,子进程残留资源(PCB)存放于内核中,变成僵尸(Zombie)进程。
僵尸进程本身无害(不耗资源),但过多会占用 PID;
僵尸进程不能被 ki11 -9 杀死。
这样就会导致一个问题,如果父进程不调用wait()或 waitpid()的话,那么保留的那段信息就不会释放,其进程号就会一直被占用,但是系统所能使用的进程号是有限的,如果大量的产生僵尸进程,将因为没有可用的进程号而导致系统不能产生新的进程,此即为僵尸进程的危害,应当避免。
方案:
1. 在父进程中正确使用 wait()/waitpid():
- 同步等待: 如果父进程需要等待特定的子进程结束再继续,可以在 fork() 后立即或在适当位置调用 wait(&status) 或 waitpid(child_pid, &status, 0)。这会阻塞父进程,直到指定的子进程结束。
- 异步等待(轮询): 如果父进程不能阻塞,可以在其主循环中定期、非阻塞地调用 waitpid():
2. 捕获 SIGCHLD 信号并处理:
- 这是处理僵尸进程最常用且高效的方式,尤其适合服务端父进程需要处理大量短暂子进程的情况。
- 父进程注册一个 SIGCHLD 信号处理函数。当子进程状态改变(终止、停止、继续)时,内核会向父进程发送 SIGCHLD 信号。
- 在信号处理函数中,必须使用 waitpid() 配合 WNOHANG 来循环回收所有已结束的子进程:
void sigchld_handler(int sig) {int saved_errno = errno; // 保存 errno,因为 waitpid 可能会改变它pid_t wpid;int status;// 循环回收所有已终止的子进程while ((wpid = waitpid(-1, &status, WNOHANG)) > 0) {// 可选:记录子进程结束信息 (wpid, status)}if (wpid < 0 && errno != ECHILD) {// 处理 waitpid 错误(非“没有子进程”错误)perror("waitpid in handler");}errno = saved_errno; // 恢复 errno
}
// 在主函数中设置信号处理器
struct sigaction sa;
sa.sa_handler = sigchld_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART | SA_NOCLDSTOP; // SA_RESTART: 重启被中断的系统调用; SA_NOCLDSTOP: 忽略子进程停止/继续信号
if (sigaction(SIGCHLD, &sa, NULL) == -1) {perror("sigaction");exit(1);
}
关键点: 信号处理函数必须使用 WNOHANG 循环调用 waitpid,因为信号是不可靠的,多个子进程同时结束可能只合并发送一次 SIGCHLD 信号。循环确保回收所有已结束的子进程。
避免在信号处理函数中调用非异步信号安全函数(如 printf, malloc)。 通常只需记录日志或设置标志位,在主循环中处理复杂逻辑。
3. fork() 两次(双 fork 技巧):
目标:让父进程完全不需要关心孙进程的结束状态,由 init 进程自动回收。
步骤:
- 父进程 fork() 出子进程 A;
- 子进程 A 立即 fork() 出孙进程 B;
- 子进程 A 立即退出;
- 父进程调用 waitpid() 等待子进程 A 结束。此时子进程 A 结束,父进程回收它,不会变成僵尸;
- 孙进程 B 继续执行其任务。当孙进程 B 结束时,它变成了孤儿进程,被 init 进程(PID 1)收养。init 进程会定期调用 wait() 回收其收养的所有孤儿进程(包括僵尸孤儿)。因此,孙进程 B 结束后的状态会被 init 自动回收,不会变成长期僵尸。
适用场景:父进程(如守护进程)只想启动一个完全独立的子进程,并且不想处理它的结束状态。常用于守护进程创建后台工作进程。
4. 终止父进程(最后手段):
- 如果僵尸进程的父进程是一个可以安全终止的进程(例如,一个你自己编写的有缺陷的程序),那么杀死父进程(使用 kill 命令)是强制解决其所有僵尸子进程的最直接方法。
- 当父进程被终止后,其所有子进程(包括僵尸进程)会被 init 进程收养。init 进程随后会调用 wait() 来清理这些僵尸进程。
- 这只应在明确知道父进程可以终止且没有其他负面影响时使用。不能随意终止系统关键进程或重要服务的父进程。
三、多线程
多线程应对多任务:
3.1 多线程如何提高程序执行效率的
(1)多线程的核心优势:资源共享与轻量协作
进程 vs 线程的资源分配:
- 进程:启动时分配独立的内存、文件句柄等资源,资源隔离性强,但创建和切换成本高。
- 线程:共享同一进程的内存和资源,创建和切换成本极低(仅需分配栈和寄存器)。
(2)多线程如何提升效率?
利用 CPU 空闲时间(I/O 等待与计算重叠):
- 单线程问题:如果程序需要频繁等待 I/O(如读写文件、网络请求),CPU 会空闲。
- 多线程解决:线程 A 等待 I/O 时,操作系统可调度线程 B 使用 CPU。
多核并行计算(CPU 密集型任务):
- 单线程限制:单线程只能利用一个 CPU 核心。
- 多线程解决:将任务拆分为多个子任务,每个线程运行在一个核心上;例如:视频编码软件用多线程并行处理不同帧,速度显著提升。
减少任务响应延迟(实时性提升):
- 单线程问题:一个耗时操作会阻塞整个程序(如 GUI 界面卡死)。
- 多线程解决:后台线程处理耗时任务,主线程保持界面响应;例如:浏览器用多线程渲染页面时,用户仍可滚动或点击。
(3)多线程的代价与注意事项
- 线程切换开销:频繁切换线程可能浪费 CPU 周期(需权衡线程数量)。
- 同步问题:共享资源需加锁(如互斥锁),不当使用会导致死锁或性能下降。
- 单核环境下适用的场景:适合I/O 密集型、可并行计算的任务;不适合单核纯 CPU 密集型任务。
(4)底层优化
- 超线程技术:单个物理核心模拟多个逻辑核心,进一步提升并行度;
- 线程池:复用已创建的线程,减少频繁创建/销毁的开销;
- 异步 I/O:结合多线程,进一步减少等待时间(如 Node.js 的 libuv 库)。
关键公式:总时间 ≈ (任务时间 / 核数) + 线程切换开销 + 同步延迟。
(5)多线程是否依赖多核?
- 单核 CPU 中的多线程:操作系统通过时间片轮转调度,让多个线程交替执行(看似“同时”运行)。
- 多核 CPU 中的多线程:线程可以分配到不同核心上真正并行执行,加速计算密集型任务。
3.2 多线程的创建、等待和中止
(1)线程创建
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
参数:
- thread:获取创建成功的线程ID,该参数是一个输出型参数
- attr:设置线程的属性,attr为NULL表示使用默认属性
- start_routine:是个函数地址,线程启动后要执行的函数
- arg:传给线程启动函数的参数
返回值:
- 成功返回0
- 失败返回错误码
#include<stdio.h>
#include<pthread.h>
#include<unistd.h>
void* thread_run(void* args)
{ const char* id = (const char*)args; while(1){ printf("I am %s thread, %d\n", id, getpid()); sleep(1); }
}
int main()
{ pthread_t tid; //定义一个线程ID pthread_create(&tid, NULL, thread_run, (void*)"thread 1"); while(1){ printf("I am mian thread, %d\n",getpid()); sleep(1); } return 0;
}
程序一旦运行起来,首先进程被创建,于此同时一个线程也被创建,这就是主线程;此时主线程通过pthread函数来创建其他线程。
如何获取线程ID:
- 创建线程时通过输出型参数获得。
- 通过调用pthread_self函数获得。
(2)线程等待
一个线程被创建出来,就如同进程一般,也是需要被等待的。如果主线程不对新线程进行等待,那么这个新线程的资源也是不会被回收的。所以线程需要被等待,如果不等待会产生类似于“僵尸进程”的问题,也就是内存泄漏。
线程等待的函数:
int pthread_join(pthread_t thread, void **retval);
参数说明:
- thread:被等待线程的ID
- retval:它是一个输出型参数,用来获取新线程退出的时候,函数的返回值;新线程函数的返回值是void*,所以要获取一级指针的值,就需要二级指针,也就是void**
返回值:
- 成功返回0
- 失败返回错误码
调用该函数的线程将挂起等待,直到id为thread的线程终止。thread线程以不同的方法终止,通过pthread_join得到的终止状态是不同的,总结如下:
- 如果thread线程通过return返回,retval所指向的单元里存放的是thread线程函数的返回值。
- 如果thread线程被别的线程调用pthread_ cancel异常终掉,retval所指向的单元里存放的是常数PTHREAD_ CANCELED。
- 如果thread线程是自己调用pthread_exit终止的,retval所指向的单元存放的是传给pthread_exit的参数。
- 如果对thread线程的终止状态不感兴趣,可以传NULL给retval参数。
#include <stdio.h>
#include <pthread.h>
#include <unistd.h> void* thread_run(void* args)
{int num = *(int*)args;while(1){printf("I am new thread [%d], I creat thread ID: %lu\n", num, pthread_self());sleep(3);break;}return (void*)111;
}int main()
{pthread_t tid[NUM];for(int i = 0; i < NUM; i++){pthread_create(tid + i, NULL, thread_run, (void*)&i);sleep(1);}void* status = NULL;pthread_join(tid[0], &status);printf("ret: %d\n", (int)status);return 0;
}
pthread_join函数默认是以阻塞的方式进行线程等待的。它只有等待线程退出后才可以拿到退出码。
(3)线程中止
如果需要只终止某个线程而不终止整个进程,可以有三种方法:
- 从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit。
- 线程可以调用pthread_ exit函数终止自己。
- 一个线程可以调用pthread_ cancel函数终止同一进程中的另一个线程。
线程终止的函数(自己终止):
void pthread_exit(void *retval);
retval:线程退出时的退出码信息
- 该函数无返回值,跟进程一样,线程结束的时候无法返回它的调用者(自身)。
- pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其他线程得到这个返回指针时,线程函数已经退出了。
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#include <stdlib.h>
#define NUM 5
void* thread_run(void* args)
{ int num = *(int*)args; while(1){ printf("I am new thread [%d], I creat thread ID: %lu\n", num, pthread_self()); sleep(3); break; } //exit(111); //1pthread_exit((void*)111); //2
} int main()
{ pthread_t tid[NUM]; for(int i = 0; i < NUM; i++){ pthread_create(tid + i, NULL, thread_run, (void*)&i); sleep(1); } void* status = NULL; for(int i = 0; i < NUM; i++){ pthread_join(tid[i], &status); printf("I am thread[%d], I am code: %d\n",i ,(int)status); } while(1){ printf("I am main thread\n"); sleep(1); } return 0;
}
当我们在新线程中调用pthread_exit函数时,只会将新线程终止,不会影响到主线程。
当我们在新线程中调用exit函数时,直接将进程退出了。
线程终止的函数:(让别人终止)
int pthread_cancel(pthread_t thread);
参数:
- thread:被取消线程的ID。
返回值:
- 线程取消成功返回0,失败返回错误码。
线程是可以取消自己的,取消成功的线程的退出码一般是-1。
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#include <stdlib.h>
void* thread_run(void* args)
{ while(1){ printf("I am new thread [%s], I creat thread ID: %lu\n", (const char*)args, pthread_self()); sleep(1); }
}
int main()
{ pthread_t tid; pthread_create(&tid, NULL, thread_run, (void*)"thread 1"); sleep(3); printf("wait new thread...\n");//主线程休眠3秒后,提示在等待线程 sleep(10); printf("cancel wait new thread...\n");//主线等待10秒之后,提示取消等待 pthread_cancel(tid); //调用函数,取消新线程 void* status = NULL; pthread_join(tid, &status); //和获取新线程退出时的退出码 printf("I am thread: %d, I am code: %d\n",tid ,(int)status); return 0; }
通过运行结果发现,当主线程取消新线程后,新线程终止,返回的退出码是-1。
(4)线程分离
- 默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。
- 如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。
- 可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离。
int pthread_detach(pthread_t thread);
thread:要分离的线程ID;
void* thread_run(void* args)
{ pthread_detach(pthread_self());//让新线程分离 while(1){ printf("I am new thread [%s], I creat thread ID: %lu\n", (const char*)args, pthread_self()); sleep(2); break; } return (void*)111;
}
int main()
{ pthread_t tid; int ret = 0; pthread_create(&tid, NULL, thread_run, (void*)"thread 1"); sleep(1); void* status = NULL; ret = pthread_join(tid, &status); printf("ret: %d, status: %d\n",ret ,(int)status); sleep(3); return 0;
}