在 Linux 编程的广袤世界里,进程与线程犹如大厦的基石,是理解和掌握系统运行机制的关键所在。进程,作为程序执行的一个实例,拥有独立的内存空间、文件描述符等资源,是系统进行资源分配和调度的基本单位。而线程,则是进程中的一个执行单元,它们共享进程的地址空间和资源,能够以更低的成本实现并发执行,提高程序的执行效率。
无论是开发高性能的服务器程序,还是优化复杂的系统应用,深入理解进程与线程的创建、管理和调度,都是迈向 Linux 编程高手的必经之路。在本文中,我将带领大家一步步深入探索 Linux 中进程与线程的创建奥秘,通过实际的代码示例和详细的解析,让大家对这两个核心概念有更加透彻的理解和掌握。
Part1进程和线程基础回顾
1.1进程是什么?
进程,简单来说,就是程序的一次执行过程 。当我们在 Linux 系统中运行一个程序,比如执行./a.out,系统就会为这个程序创建一个进程。从操作系统的角度看,进程是资源分配的基本单位,它拥有独立的内存空间,包括代码段(存放程序的机器指令)、数据段(存放全局变量和静态变量)、堆(用于动态内存分配)和栈(用于函数调用和局部变量存储) 。就像一个独立的小王国,进程有自己独立的 “领地” 和 “资源”。
每个进程都有自己的进程控制块(PCB,在 Linux 内核中用task_struct结构体表示),里面记录了进程的各种信息,比如进程 ID(PID)、进程状态(运行态、就绪态、阻塞态等)、优先级、打开的文件描述符列表等。这些信息就像是进程的 “身份证” 和 “档案”,操作系统通过它们来管理和调度进程。例如,当我们使用ps命令查看系统中的进程时,就能看到每个进程的 PID、占用 CPU 和内存的情况等,这些信息都来源于进程控制块。
进程之间是相互隔离的,一个进程无法直接访问另一个进程的内存空间和资源,这保证了系统的稳定性和安全性。如果一个进程出现了内存越界等错误,不会影响到其他进程的正常运行 。比如,当浏览器进程崩溃时,并不会导致音乐播放器进程也停止工作。
1.2线程是什么?
线程是进程内的执行单元,也被称为轻量级进程。一个进程可以包含多个线程,这些线程共享进程的地址空间和资源,如代码段、数据段、堆以及打开的文件描述符等 。打个比方,如果把进程比作一个工厂,那么线程就是工厂里的不同生产线,它们共享工厂的场地、设备等资源,但各自执行不同的任务。
线程有自己独立的栈空间,用于存储函数调用的局部变量、返回地址等信息,这使得每个线程在执行函数时都有自己独立的 “工作空间”,不会相互干扰 。同时,线程还有自己的寄存器,用于保存线程执行时的上下文信息,比如程序计数器(PC,指向下一条要执行的指令)、通用寄存器等 。当线程被调度执行时,CPU 会从该线程的寄存器中读取上下文信息,继续执行该线程的代码。
线程的创建和销毁开销相对较小,因为它们不需要像进程那样重新分配大量的系统资源,只是在已有的进程地址空间内创建一些必要的数据结构,如线程控制块(TCB,在 POSIX 线程库中用pthread_t类型表示) 。线程间的切换也比进程间的切换快很多,因为线程共享进程资源,切换时不需要切换地址空间等大量资源,只需要保存和恢复少量的寄存器信息即可。这使得线程在实现高并发和并行计算时具有很大的优势。例如,在一个网络服务器程序中,使用多线程可以同时处理多个客户端的连接请求,大大提高服务器的响应速度和处理能力。
1.3进程与线程的关系和区别
进程和线程是密切相关的,线程是进程的一部分,一个进程至少包含一个线程(主线程),也可以包含多个线程 。它们就像是大树和树枝的关系,进程是大树的主干,线程是从主干上生长出来的树枝,共同构成了一个完整的程序执行结构。
从资源分配的角度看,进程是资源分配的单位,拥有独立的资源;而线程是 CPU 调度的单位,基本上不拥有系统资源,但可以共享所属进程的资源 。这就好比一个公司,进程是一个个独立的部门,每个部门有自己独立的办公场地、设备等资源;而线程是部门里的员工,他们共享部门的资源,但各自有自己的工作任务和执行流程。
在通信方面,进程间通信相对复杂,需要使用专门的进程间通信(IPC)机制,如管道、消息队列、共享内存、信号量等 ,因为进程之间相互隔离,不能直接访问对方的资源。而线程间通信则相对简单,由于线程共享进程资源,它们可以直接通过全局变量等方式进行通信,但需要注意线程同步和互斥的问题,以避免数据竞争和不一致 。比如,多个线程同时访问和修改同一个全局变量时,可能会导致数据错误,这时就需要使用互斥锁、条件变量等同步机制来保证数据的一致性。
在创建和销毁开销以及上下文切换开销方面,进程的开销都比线程大 。创建一个进程需要分配独立的内存空间、初始化各种资源等,销毁时也需要释放这些资源;而创建一个线程只需要在进程地址空间内创建一些简单的数据结构,销毁时也只需要释放这些数据结构。进程间的上下文切换需要保存和恢复整个进程的上下文信息,包括地址空间等大量资源;而线程间的上下文切换只需要保存和恢复少量的寄存器信息,因此开销小很多 。这也是为什么在需要频繁创建和销毁执行单元以及进行上下文切换的场景下,线程比进程更有优势。
Part2Linux 进程创建深度剖析
2.1 fork 函数:最常用的进程创建方式
在 Linux 中,fork函数是创建新进程的最基本且常用的方式。它的原理是通过系统调用,让内核创建一个与当前进程(父进程)几乎完全相同的新进程(子进程)。这里的 “几乎完全相同”,意味着子进程复制了父进程的代码段、数据段、堆、栈以及打开的文件描述符等资源 。
从代码实现角度来看,fork函数的使用非常简洁。下面是一个简单的示例代码:
#include <stdio.h>#include <unistd.h>#include <sys/types.h>int main() { pid_t pid; // 调用fork函数创建子进程 pid = fork(); if (pid == -1) { perror("fork failed"); return 1; } else if (pid == 0) { // 子进程执行的代码段 printf("这是子进程,我的PID是 %d,父进程的PID是 %d\n", getpid(), getppid()); } else { // 父进程执行的代码段 printf("这是父进程,我的PID是 %d,创建的子进程PID是 %d\n", getpid(), pid); } return 0;}
在这个示例中,当fork函数被调用后,系统会创建一个子进程。fork函数有一个很独特的返回值特性:在父进程中,它返回新创建子进程的进程 ID;而在子进程中,它返回 0。这使得我们可以通过fork的返回值来判断当前代码是在父进程还是子进程中执行,进而实现不同的逻辑。
关于父子进程的执行顺序,在fork函数返回后,父子进程进入并发执行状态,它们的执行顺序是不确定的,这完全取决于操作系统的调度策略。在某些情况下,子进程可能先执行;而在另一些情况下,父进程可能先执行。
在资源关系方面,虽然子进程复制了父进程的大部分资源,但它们拥有各自独立的地址空间。也就是说,父子进程对相同变量的修改不会相互影响。不过,在 Linux 系统中,为了提高效率,采用了写时复制(Copy - On - Write, COW)技术。在子进程创建初期,父子进程实际上共享相同的物理内存页面,只有当其中一个进程试图修改这些共享页面时,系统才会为修改的进程复制一份物理内存页面,从而保证数据的独立性 。
2.2 vfork 函数:特殊场景下的选择
vfork函数也是 Linux 中用于创建新进程的系统调用,但它与fork函数在行为上有一些显著的差异 。
首先,vfork创建子进程时,子进程直接使用父进程的地址空间,而不是像fork那样复制一份。这意味着子进程和父进程共享数据段、堆和栈等资源,子进程对这些资源的修改会直接影响到父进程。
来看一个对比vfork和fork的示例代码:
#include <stdio.h>#include <unistd.h>#include <sys/types.h>intmain(){ int data = 100; pid_t pid; // 使用vfork创建子进程 pid = vfork(); if (pid == -1) { perror("vfork failed"); return 1; } else if (pid == 0) { data++; printf("子进程中data的值为 %d,我的PID是 %d\n", data, getpid()); _exit(0); // 子进程使用_exit()退出,避免影响父进程 } else { printf("父进程中data的值为 %d,我的PID是 %d\n", data, getpid()); } return 0;}
在这个vfork的例子中,子进程修改了data的值,父进程中data的值也随之改变,这体现了它们对数据的共享。
其次,vfork函数保证子进程先运行,直到子进程调用exec函数加载新的程序或者调用exit函数退出后,父进程才会被调度运行。这是vfork与fork的另一个重要区别。如果子进程在调用exec或exit之前依赖于父进程的进一步动作,就可能会导致死锁的情况发生。
vfork函数适用于一些特定的场景,比如当子进程需要立即执行exec系列函数加载新的程序时。由于子进程不需要独立的地址空间来运行新程序,使用vfork可以避免不必要的地址空间复制,从而提高程序的执行效率。在一些需要快速启动新进程并执行特定程序的场景中,vfork就能发挥其优势 。
2.3 clone 函数:灵活定制进程创建
clone函数是 Linux 提供的一个更为灵活的进程创建函数,它允许用户对新创建进程的资源共享和行为进行更细致的控制 。clone函数的原型如下:
#include <sched.h>#include <signal.h>#include <unistd.h>intclone(int (*fn)(void *), void *child_stack, int flags, void *arg, ...);
其中,fn是新创建进程(或线程)要执行的函数;child_stack是新进程(或线程)使用的栈空间;flags是一组标志位,用于指定新进程的特性,比如是否共享地址空间、文件描述符等;arg是传递给fn函数的参数。
clone 函数的灵活性主要体现在 flags 参数上。通过设置不同的标志位,我们可以实现不同程度的资源共享和进程特性定制。如果设置 CLONE_VM 标志位,新创建的进程将与父进程共享虚拟内存空间,类似于线程的行为;如果设置 CLONE_FS标志位,新进程将共享父进程的文件系统相关信息,如当前工作目录、文件权限掩码等 。
以下是一个使用clone函数创建一个轻量级进程(类似线程)的示例:
#include <stdio.h>#include <sched.h>#include <unistd.h>#include <stdlib.h>#include <sys/wait.h>#define STACK_SIZE (1024 * 1024)staticintchild_func(void *arg){ printf("这是子进程(轻量级),我的PID是 %d\n", getpid()); return 0;}intmain(){ void *stack = malloc(STACK_SIZE); if (!stack) { perror("malloc failed"); return 1; } int pid = clone(child_func, (char *)stack + STACK_SIZE, CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, NULL); if (pid == -1) { perror("clone failed"); free(stack); return 1; } wait(NULL); // 等待子进程结束 free(stack); return 0;}
在这个示例中,通过设置CLONE_VM、CLONE_FS、CLONE_FILES和CLONE_SIGHAND等标志位,新创建的进程与父进程共享了虚拟内存、文件系统、文件描述符和信号处理等资源,实现了轻量级进程的创建。这种方式在需要创建多个共享部分资源的执行单元时非常有用,既可以减少资源开销,又能实现一定程度的并发处理 。
Part3Linux 线程创建实战
3.1 pthread_create 函数:开启多线程之旅
在 Linux 环境下,线程的创建主要借助于 POSIX 线程库(pthread 库)中的pthread_create函数 。这个函数为我们开启了多线程编程的大门,使得我们能够充分利用多核处理器的优势,提高程序的执行效率。pthread_create函数的原型如下:
#include <pthread.h>intpthread_create(pthread_t *thread, constpthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
- thread:这是一个输出参数,类型为pthread_t,用于存储新创建线程的标识符。通过这个标识符,我们可以在后续的操作中对线程进行管理,比如等待线程结束、取消线程等 。
- attr:该参数用于设置线程的属性,是一个指向pthread_attr_t结构体的指针。如果将其设置为NULL,则表示使用默认的线程属性。线程属性涵盖了多个方面,如线程栈大小、线程分离状态、调度策略等,通过设置这些属性,我们可以定制线程的行为,以满足不同的应用场景需求 。
- start_routine:这是一个函数指针,指向新线程要执行的函数。这个函数的返回值类型必须是void*,并且接受一个void*类型的参数。当新线程被创建并启动后,它将从这个函数开始执行 。
- arg:这是传递给start_routine函数的参数,类型为void*。如果不需要传递参数,可以将其设置为NULL 。
接下来,通过一个简单的代码示例来看看pthread_create函数的实际使用:
#include <stdio.h>#include <pthread.h>#include <unistd.h>// 线程执行的函数void* thread_function(void* arg){ int num = *(int*)arg; printf("子线程开始执行,参数为: %d\n", num); sleep(1); // 模拟线程执行任务 printf("子线程执行结束\n"); return NULL;}intmain(){ pthread_t thread; int param = 10; // 创建线程 int result = pthread_create(&thread, NULL, thread_function, (void*)¶m); if (result != 0) { printf("线程创建失败,错误码: %d\n", result); return 1; } printf("主线程继续执行\n"); // 等待线程结束 void* ret; pthread_join(thread, &ret); return 0;}
- 首先定义了一个thread_function函数,它就是新线程要执行的函数。这个函数接受一个void*类型的参数,并将其转换为int类型后打印出来,然后通过sleep函数模拟线程执行任务,最后返回NULL 。
- 在main函数中,定义了一个pthread_t类型的变量thread,用于存储新线程的标识符。同时定义了一个int类型的变量param,并初始化为 10,作为传递给线程函数的参数 。
- 调用pthread_create函数创建线程,将thread、NULL(表示使用默认线程属性)、thread_function和(void*)¶m作为参数传递进去。如果pthread_create函数返回值不为 0,则表示线程创建失败,打印错误信息并返回 。
- 最后调用pthread_join函数等待线程结束。pthread_join函数的第一个参数是要等待的线程标识符,第二个参数用于存储线程的返回值(这里我们不需要获取返回值,所以可以将其设置为NULL)。通过pthread_join函数,主线程会阻塞,直到指定的线程执行完毕,这样可以确保主线程在子线程结束后再退出 。
3.2线程属性设置:个性化你的线程
在 Linux 线程编程中,线程属性的设置为我们提供了极大的灵活性,使我们能够根据具体的应用需求来定制线程的行为 。通过 pthread_attr_t 结构体及其相关函数,我们可以设置线程的多个属性,以下将详细介绍一些常用的属性设置方法及其影响 。
(1)设置线程栈大小
线程栈是线程执行函数时用于存储局部变量、函数调用栈等信息的内存区域。默认情况下,线程栈大小由系统或库的默认值决定,但在某些场景下,我们可能需要调整线程栈大小 。
设置线程栈大小可以使用pthread_attr_setstacksize函数,其原型如下:
#include <pthread.h>intpthread_attr_setstacksize(pthread_attr_t *attr, size_t stacksize);
其中,attr是指向pthread_attr_t结构体的指针,stacksize是要设置的线程栈大小,单位是字节 。
下面是一个设置线程栈大小的示例代码:
#include <stdio.h>#include <pthread.h>#include <stdlib.h>#define STACK_SIZE (1024 * 1024) // 1MB 栈大小// 线程执行的函数void* thread_function(void* arg){ char buffer[512 * 1024]; // 模拟使用较大的栈空间 printf("子线程执行,使用自定义栈大小\n"); return NULL;}intmain(){ pthread_t thread; pthread_attr_t attr; // 初始化线程属性对象 pthread_attr_init(&attr); // 设置线程栈大小 pthread_attr_setstacksize(&attr, STACK_SIZE); // 创建线程 int result = pthread_create(&thread, &attr, thread_function, NULL); if (result != 0) { printf("线程创建失败,错误码: %d\n", result); return 1; } // 等待线程结束 void* ret; pthread_join(thread, &ret); // 销毁线程属性对象 pthread_attr_destroy(&attr); return 0;}
- 首先定义了一个宏STACK_SIZE,表示要设置的线程栈大小为 1MB 。
- 在main函数中,初始化了一个pthread_attr_t类型的变量attr,用于存储线程属性。
- 调用pthread_attr_setstacksize函数,将attr和STACK_SIZE作为参数传递进去,设置线程栈大小为 1MB 。
- 然后使用设置了属性的attr来创建线程,确保新线程使用我们设置的栈大小 。
- 线程执行完毕后,调用pthread_attr_destroy函数销毁线程属性对象,释放相关资源 。
如果线程栈设置过小,当线程中需要使用较多的栈空间(如大量的局部变量、深层的递归调用等)时,可能会导致栈溢出错误,使程序崩溃;而设置过大的线程栈,则会浪费内存资源,特别是在创建大量线程的情况下,会对系统内存造成较大压力 。
(2)设置线程分离属性
线程的分离属性决定了线程结束时的资源回收方式 。在默认情况下,线程是非分离状态(PTHREAD_CREATE_JOINABLE),这种情况下,线程结束后,其线程 ID 和退出状态会被保留,直到其他线程调用pthread_join函数来回收资源 。而分离状态(PTHREAD_CREATE_DETACHED)的线程在结束时,会自动释放所有资源,无需其他线程等待 。
设置线程分离属性可以使用pthread_attr_setdetachstate函数,其原型如下:
#include<pthread.h>intpthread_attr_setdetachstate(pthread_attr_t*attr,intdetachstate);
其中,attr是指向pthread_attr_t结构体的指针,detachstate可以取值为PTHREAD_CREATE_DETACHED(分离状态)或PTHREAD_CREATE_JOINABLE(非分离状态) 。
以下是一个设置线程分离属性的示例代码:
#include <stdio.h>#include <pthread.h>// 线程执行的函数void* thread_function(void* arg){ printf("子线程开始执行\n"); sleep(1); // 模拟线程执行任务 printf("子线程执行结束\n"); return NULL;}intmain(){ pthread_t thread; pthread_attr_t attr; // 初始化线程属性对象 pthread_attr_init(&attr); // 设置线程为分离状态 pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); // 创建线程 int result = pthread_create(&thread, &attr, thread_function, NULL); if (result != 0) { printf("线程创建失败,错误码: %d\n", result); return 1; } printf("主线程继续执行,无需等待子线程结束\n"); // 销毁线程属性对象 pthread_attr_destroy(&attr); return 0;}
- 在main函数中,初始化 pthread_attr_t 变量 attr 后,调用pthread_attr_setdetachstate 函数将其设置为分离状态 。
- 使用设置了分离属性的attr创建线程,此时主线程创建完子线程后可以继续执行,无需等待子线程结束,子线程结束时会自动释放资源 。
当我们创建的线程执行的任务是独立的,不需要获取其执行结果,或者希望线程结束后立即释放资源,避免资源占用时,就可以将线程设置为分离状态 。但要注意,如果设置了线程为分离状态,就不能再调用pthread_join函数等待该线程结束,否则会返回错误 。
Part4进程与线程创建的常见问题及解决方案
4.1资源竞争与同步问题
在多进程和多线程编程中,资源竞争是一个常见且必须重视的问题。当多个进程或线程同时访问和修改共享资源时,就可能引发资源竞争,导致数据不一致或程序逻辑错误 。这是因为在并发环境下,各个进程或线程的执行顺序是不确定的,它们对共享资源的访问操作可能会相互干扰。比如多个线程同时对一个全局变量进行加 1 操作,如果没有适当的同步措施,最终的结果可能并非是每个线程加 1 操作的累加,而是会出现数据错误。为了解决进程和线程间的资源竞争问题,我们需要引入同步机制。互斥锁(Mutex)和信号量(Semaphore)是两种常用的同步工具 。
互斥锁的工作原理是基于 “互斥访问” 的概念,它就像一把锁,在同一时间只允许一个进程或线程持有这把锁,从而进入临界区(访问共享资源的代码段)。当一个进程或线程获取到互斥锁后,其他进程或线程就必须等待,直到该锁被释放 。在 Linux 的 pthread 库中,互斥锁相关的函数主要有pthread_mutex_init(用于初始化互斥锁)、pthread_mutex_lock(用于获取互斥锁,如果锁已被占用,则调用线程会阻塞)、pthread_mutex_unlock(用于释放互斥锁)和pthread_mutex_destroy(用于销毁互斥锁) 。下面是一个使用互斥锁来保护共享资源的代码示例:
#include <stdio.h>#include <pthread.h>// 共享资源int shared_data = 0;// 互斥锁pthread_mutex_t mutex;// 线程执行的函数void* thread_function(void* arg){ for (int i = 0; i < 1000; ++i) { // 获取互斥锁 pthread_mutex_lock(&mutex); shared_data++; // 释放互斥锁 pthread_mutex_unlock(&mutex); } return NULL;}intmain(){ pthread_t thread1, thread2; // 初始化互斥锁 pthread_mutex_init(&mutex, NULL); // 创建线程 pthread_create(&thread1, NULL, thread_function, NULL); pthread_create(&thread2, NULL, thread_function, NULL); // 等待线程结束 pthread_join(thread1, NULL); pthread_join(thread2, NULL); // 销毁互斥锁 pthread_mutex_destroy(&mutex); printf("最终共享数据的值为: %d\n", shared_data); return 0;}
在这个示例中,通过 pthread_mutex_lock和pthread_mutex_unlock 函数来确保在任何时刻只有一个线程能够访问和修改 shared_data ,从而避免了资源竞争问题 。
信号量则是一个更通用的同步工具,它通过一个计数器来控制对共享资源的访问。当一个进程或线程想要访问共享资源时,它需要先获取信号量,如果信号量的计数器大于 0,则获取成功,计数器减 1;如果计数器为 0,则获取失败,调用者会被阻塞,直到有其他进程或线程释放信号量(计数器加 1) 。在 Linux 中,信号量相关的函数有sem_init(用于初始化信号量)、sem_wait(用于获取信号量,会阻塞直到信号量可用)、sem_post(用于释放信号量)和sem_destroy(用于销毁信号量) 。
下面是一个使用信号量来实现进程间同步的示例:
#include <stdio.h>#include <stdlib.h>#include <semaphore.h>#include <sys/mman.h>#include <fcntl.h>#include <unistd.h>#include <sys/wait.h>#define SHM_SIZE 1024// 共享内存结构typedef struct { int data;} SharedData;intmain(){ int shm_fd; SharedData *shared_data; sem_t *semaphore; // 创建共享内存对象 shm_fd = shm_open("/shared_memory", O_CREAT | O_RDWR, 0666); if (shm_fd == -1) { perror("shm_open"); return 1; } // 映射共享内存到进程地址空间 shared_data = (SharedData*)mmap(0, SHM_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, shm_fd, 0); if (shared_data == MAP_FAILED) { perror("mmap"); close(shm_fd); return 1; } // 初始化共享数据 shared_data->data = 0; // 创建信号量 semaphore = sem_open("/semaphore", O_CREAT, 0666, 1); if (semaphore == SEM_FAILED) { perror("sem_open"); munmap(shared_data, SHM_SIZE); close(shm_fd); shm_unlink("/shared_memory"); return 1; } pid_t pid = fork(); if (pid == -1) { perror("fork"); sem_close(semaphore); sem_unlink("/semaphore"); munmap(shared_data, SHM_SIZE); close(shm_fd); shm_unlink("/shared_memory"); return 1; } else if (pid == 0) { // 子进程 for (int i = 0; i < 1000; ++i) { sem_wait(semaphore); shared_data->data++; sem_post(semaphore); } exit(0); } else { // 父进程 for (int i = 0; i < 1000; ++i) { sem_wait(semaphore); shared_data->data++; sem_post(semaphore); } wait(NULL); // 等待子进程结束 printf("最终共享数据的值为: %d\n", shared_data->data); // 清理资源 sem_close(semaphore); sem_unlink("/semaphore"); munmap(shared_data, SHM_SIZE); close(shm_fd); shm_unlink("/shared_memory"); } return 0;}
在这个示例中,通过信号量来控制父子进程对共享内存中数据的访问,确保了数据的一致性 。无论是互斥锁还是信号量,在使用时都需要注意正确的初始化和销毁操作,以避免资源泄漏和程序错误 。
4.2线程安全问题
线程安全是多线程编程中一个至关重要的概念,它关乎着程序在多线程环境下的正确性和稳定性 。简单来说,线程安全是指当多个线程访问某个代码块或共享资源时,不会出现数据不一致或程序逻辑混乱的情况,程序的行为仍然符合预期 。
在实际的多线程编程中,存在许多常见的线程不安全场景。对全局变量的访问是一个典型的例子。由于多个线程共享进程的地址空间,它们都可以访问和修改全局变量。如果没有适当的同步机制,当多个线程同时对全局变量进行读写操作时,就可能出现数据竞争问题。假设有一个全局变量count,两个线程同时对其进行加 1 操作,由于线程执行顺序的不确定性,可能会导致其中一个线程的加 1 操作被覆盖,最终count的值并非是两次加 1 后的结果 。
静态变量的使用也存在类似的问题。静态变量在程序的整个生命周期内都存在,并且在多个线程间共享。如果在多线程环境中对静态变量进行无保护的读写操作,同样容易引发线程安全问题 。
函数状态随着被调用而发生变化的情况也可能导致线程不安全。一个函数内部维护了一些状态变量,当多个线程同时调用这个函数时,这些状态变量的变化可能会相互干扰,导致函数的执行结果不可预测 。
为了避免线程安全问题,我们可以采取多种措施。最基本的方法是使用同步机制,如前面提到的互斥锁、信号量等,来保护共享资源的访问 。在 C++11 中,引入了原子操作(Atomic Operations),它可以确保对共享数据的操作是原子性的,即不可分割的,从而避免了数据竞争问题。使用std::atomic<int>类型来定义一个原子变量,对其进行自增操作时,就不需要额外的锁来保证线程安全 。
#include <iostream>#include <atomic>#include <thread>std::atomic<int> count(0);voidincrement(){ for (int i = 0; i < 1000; ++i) { count++; }}intmain(){ std::thread thread1(increment); std::thread thread2(increment); thread1.join(); thread2.join(); std::cout << "最终count的值为: " << count << std::endl; return 0;}
在这个示例中,std::atomic<int>类型的count变量保证了自增操作的原子性,即使在多线程环境下也能正确执行 。尽量减少共享数据的范围也是一种有效的方法。如果能将数据限制在单个线程内部使用,就可以完全避免线程安全问题。可以使用线程局部存储(Thread - Local Storage, TLS),为每个线程提供独立的变量副本,从而避免共享 。在 C++ 中,可以使用thread_local关键字来定义线程局部变量 。
#include <iostream>#include <thread>thread_local int local_data = 0;voidthread_function(){ for (int i = 0; i < 1000; ++i) { local_data++; } std::cout << "线程中local_data的值为: " << local_data << std::endl;}intmain(){ std::thread thread1(thread_function); std::thread thread2(thread_function); thread1.join(); thread2.join(); return 0;}
在这个例子中,local_data是一个线程局部变量,每个线程都有自己独立的副本,因此不存在线程安全问题 。在设计多线程程序时,合理的架构和数据结构选择也能减少线程安全问题的出现 。
4.3内存管理问题
在进程和线程创建过程中,内存管理是一个不可忽视的重要环节,它直接关系到程序的稳定性、性能以及资源利用率 。无论是进程还是线程,在运行过程中都需要分配和使用内存来存储数据和执行代码,而不当的内存管理可能会引发一系列严重的问题 。
堆内存的分配与释放是内存管理中的一个关键要点 。在 C 和 C++ 语言中,我们通常使用malloc、free(C 语言)或new、delete(C++ 语言)来进行堆内存的分配和释放操作 。在多线程环境下,这些操作需要特别小心。如果多个线程同时进行堆内存的分配和释放,可能会导致内存泄漏、悬空指针等问题 。假设一个线程分配了一块堆内存,然后将指向这块内存的指针传递给另一个线程,而第一个线程在第二个线程使用完这块内存之前就将其释放了,那么第二个线程就会持有一个悬空指针,当它试图访问该指针所指向的内存时,就会引发未定义行为 。
为了避免这种情况,我们可以使用线程安全的内存分配和释放函数,或者在进行内存操作时使用同步机制来保护这些操作 。在 C++ 中,可以使用智能指针(如std::unique_ptr、std::shared_ptr)来管理堆内存,它们能够自动处理内存的释放,从而减少内存泄漏的风险 。
#include <iostream>#include <memory>#include <thread>// 使用std::shared_ptr管理共享资源std::shared_ptr<int> shared_resource;voidthread_function(){ // 线程中使用共享资源 if (shared_resource) { std::cout << "线程中访问共享资源的值: " << *shared_resource << std::endl; }}intmain(){ // 分配共享资源 shared_resource = std::make_shared<int>(100); std::thread thread1(thread_function); std::thread thread2(thread_function); thread1.join(); thread2.join(); // 共享资源会在最后一个std::shared_ptr对象销毁时自动释放 return 0;}
在这个示例中,std::shared_ptr确保了共享资源在不再被使用时能够自动释放,避免了手动管理内存释放可能带来的问题 。
线程栈溢出也是一个需要关注的内存管理问题 。每个线程都有自己独立的栈空间,用于存储局部变量、函数调用栈等信息 。如果线程中使用的栈空间超过了系统或线程属性设置的栈大小限制,就会发生栈溢出错误,导致程序崩溃 。在递归函数中,如果递归深度过深,就很容易引发栈溢出 。
#include <stdio.h>voidrecursive_function(int depth) { int local_variable[1024]; // 占用一定栈空间 if (depth > 10000) { return; } recursive_function(depth + 1);}intmain() { recursive_function(0); return 0;}
在这个示例中,recursive_function 函数每递归一次,就会在栈上分配 local_variable 数组的空间,当递归深度过大时,就可能导致栈溢出 。为了防止栈溢出,我们可以合理设置线程栈大小,根据线程的实际需求来调整栈空间的大小;同时,在编写代码时,要注意避免不必要的深层递归调用,或者使用迭代的方式来替代递归 。
在 Linux 中,可以通过 pthread_attr_setstacksize 函数来设置线程的栈大小 。在进程和线程创建及运行过程中,良好的内存管理是保证程序正常运行的基础。我们需要充分了解各种内存管理问题的产生原因,并采取有效的措施来避免这些问题,以提高程序的健壮性和稳定性 。
Part5进程和线程高频面试题
5.1Linux 中创建进程的方式有哪些?
在 Linux 中,主要有以下三种创建进程的方式:
- 使用fork函数:fork函数是创建新进程最常用的方式,它被调用一次,但会返回两次。在父进程中,返回值是新创建子进程的 PID(进程 ID,是一个大于 0 的整数);在子进程中,返回值是 0 。这是因为父进程需要知道子进程的 PID 以便进行后续的进程管理,如等待子进程结束、获取子进程的退出状态等;而子进程通过返回 0 来标识自己是新创建的进程。子进程会复制父进程的几乎所有资源,包括内存空间(采用写时复制技术,即 COW,Copy - On - Write ,在子进程没有对内存进行写操作时,父子进程共享相同的物理内存页面,只有当子进程进行写操作时,才会为子进程分配新的物理内存页面)、文件描述符、环境变量等 。这使得子进程在创建初期与父进程非常相似,但它们是相互独立的进程,拥有各自的进程控制块(PCB,Process Control Block ,在 Linux 内核中用task_struct结构体表示)。
- 使用vfork函数:vfork函数也用于创建新进程,与fork函数有一些关键区别。vfork创建的子进程与父进程共享地址空间,这意味着子进程对内存的修改会直接影响到父进程,因此子进程在调用exec系列函数(用于执行一个新的程序,完全替换当前进程的内存映像)或exit函数(用于终止进程)之前,不能对数据进行修改,否则会导致不可预测的结果 。在子进程调用exec或exit之前,父进程会被阻塞,暂停执行,直到子进程完成这两个操作之一,这样可以确保子进程在使用共享地址空间时,父进程不会对其进行干扰 。vfork主要用于子进程需要立即执行一个新程序的场景,由于避免了复制整个地址空间的开销,所以在这种特定场景下比fork更高效 。
- 使用clone函数:clone函数提供了比fork和vfork更细粒度的控制,它可以通过传入不同的标志位来指定子进程与父进程之间共享的资源 。其函数原型为int clone(int (*fn)(void *), void *child_stack, int flags, void *arg, ... /* pid_t *ptid, struct user_desc *tls, pid_t *ctid */ ); ,其中fn是子进程开始执行的函数指针,child_stack是子进程使用的栈指针,flags是标志位,用于控制资源共享和进程创建的行为,arg是传递给fn函数的参数 。例如,如果设置CLONE_VM标志,子进程与父进程共享内存空间;设置CLONE_FILES标志,子进程与父进程共享文件描述符表等 。clone函数非常灵活,可以用于创建线程(通过设置CLONE_THREAD标志,使得子进程与父进程共享相同的线程组,在 Linux 中,线程本质上是一种特殊的进程,它们共享进程的地址空间和其他资源),也可以根据具体需求创建具有不同资源共享特性的轻量级进程 。
总的来说,fork适用于创建完全独立的子进程执行不同任务;vfork适用于子进程马上要执行exec系列函数的场景以节省内存开销;clone则用于需要精确控制子进程与父进程资源共享情况的复杂场景,如实现线程或特定的轻量级进程模型。
5.2使用 fork 创建进程的原理和步骤是什么?
使用fork创建进程的原理和步骤如下:
- 系统调用:当一个进程调用fork函数时,会触发一个系统调用,陷入内核态。这是因为创建进程涉及到操作系统对系统资源的管理和分配,需要内核的权限和支持。例如,在 Linux 系统中,fork函数会通过软中断(如int 0x80或syscall指令,具体取决于系统架构和内核版本)进入内核。
- 内核创建子进程的数据结构:内核为新的子进程创建一个进程控制块(PCB,在 Linux 内核中用task_struct结构体表示)。这个结构体包含了进程的各种信息,如进程 ID(PID)、进程状态、打开的文件描述符表、信号处理函数表、内存管理信息(如指向内存描述符mm_struct的指针,mm_struct管理着进程的虚拟地址空间和物理内存映射关系)等 。新的task_struct中的大部分信息会从父进程的task_struct复制而来,但也有一些是独立生成的,比如新的 PID 。
- 内存资源处理(写时复制,COW):传统上,创建子进程时需要复制父进程的整个内存空间,包括代码段、数据段、堆和栈等。但现代 Linux 系统采用写时复制(Copy - On - Write,COW)技术来优化这一过程。子进程的页表(用于虚拟地址到物理地址的映射)会被创建,并且页表项(PTE,Page Table Entry)初始时指向与父进程相同的物理内存页面 。这些页面被标记为只读,当父进程或子进程尝试对这些共享页面进行写操作时,会触发一个页错误(page fault) 。内核的页错误处理程序会检测到这是一个写时复制的页面,然后为触发写操作的进程分配一个新的物理页面,将原来页面的内容复制到新页面,再修改该进程的页表,使其指向新的物理页面,并将新页面标记为可写 。这样,只有在真正需要写操作时才会复制内存页面,减少了创建子进程时的内存开销和时间开销。
- 文件描述符复制:父进程的文件描述符表也会被复制到子进程中。这意味着子进程可以访问父进程打开的所有文件,并且文件指针的位置在父子进程中是相同的 。例如,如果父进程打开了一个文件并读取到了文件的某个位置,子进程继承了这个文件描述符后,也会从相同的位置继续读取(或写入)文件 。但文件描述符表的复制并不是简单的指针复制,而是每个文件描述符在内核中对应的文件结构体的引用计数增加,这样当父进程或子进程关闭文件描述符时,只有当引用计数为 0 时,内核才会真正关闭文件 。
- 返回值处理:fork函数会返回两次,一次在父进程中,一次在子进程中 。在父进程中,fork返回子进程的 PID 。这个返回值让父进程可以跟踪和管理子进程,比如可以通过wait系列函数等待子进程结束,并获取子进程的退出状态 。在子进程中,fork返回 0 。子进程通过返回 0 来知道自己是新创建的进程,可以执行与父进程不同的代码逻辑 。如果fork调用失败(例如系统资源不足无法创建新进程),则在父进程中返回 -1,并设置相应的错误号(如errno),可以通过perror函数查看具体的错误信息 。
例如,以下是一个简单的使用fork创建进程的 C 语言代码示例:
#include <stdio.h>#include <stdlib.h>#include <unistd.h>#include <sys/types.h>intmain(){ pid_t pid; // 调用fork创建子进程 pid = fork(); if (pid == -1) { perror("fork error"); exit(EXIT_FAILURE); } else if (pid == 0) { // 子进程执行的代码 printf("I am the child process, my pid is %d, my parent's pid is %d\n", getpid(), getppid()); } else { // 父进程执行的代码 printf("I am the parent process, my pid is %d, my child's pid is %d\n", getpid(), pid); } return 0;}
在这个例子中,fork函数被调用后,会创建一个子进程。根据fork的返回值,父子进程分别执行不同的代码块,输出各自的进程信息。
5.3 在 Linux 中如何创建线程?
在 Linux 中,通常使用 POSIX 线程库(pthread 库)来创建线程 ,主要使用pthread_create函数,其函数原型为:
#include <pthread.h>intpthread_create(pthread_t *thread, constpthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
- thread:指向pthread_t类型变量的指针,用来存储新创建线程的线程 ID,通过这个 ID 可以对线程进行管理,如等待线程结束(pthread_join)、分离线程(pthread_detach)等操作。
- attr:指向pthread_attr_t类型的结构体指针,用于设置线程的属性,比如栈大小、调度策略、优先级等 。如果设置为NULL,则表示使用默认的线程属性 。例如,如果想要设置线程的栈大小,可以先初始化一个pthread_attr_t结构体,然后使用pthread_attr_setstacksize函数来设置栈大小,再将这个结构体作为attr参数传入pthread_create函数。
- start_routine:这是一个函数指针,指向线程开始执行时要调用的函数,该函数就是线程的执行体 ,其返回值类型必须为void*,参数类型也为void* 。当线程创建成功后,新线程就会从这个函数开始执行。
- arg:传递给start_routine函数的参数 ,如果start_routine函数不需要参数,可以将其设置为NULL 。由于arg的类型是void*,所以可以传递任意类型的数据指针,但在使用时需要进行正确的类型转换。
返回值:函数调用成功时返回 0;如果失败,会返回一个非零的错误码,常见的错误码有EAGAIN(表示系统资源不足,无法创建新线程,比如达到了系统允许的最大线程数限制 ,或者内存不足无法为新线程分配栈空间等)、EINVAL(表示attr参数无效,例如设置了不支持的线程属性值)、EPERM(表示调用者没有权限设置attr中指定的某些属性,如设置实时调度策略和优先级时,可能需要特定的权限)等 。可以通过strerror函数将错误码转换为对应的错误信息字符串,以便于调试和定位问题。
下面是一个简单的使用pthread_create创建线程的示例代码:
#include <stdio.h>#include <pthread.h>#include <stdlib.h>#include <string.h>// 线程执行函数void* thread_function(void* arg){ char* message = (char*)arg; printf("%s\n", message); pthread_exit(NULL);}intmain(){ pthread_t thread; char* message = "Hello from thread!"; int result; // 创建线程 result = pthread_create(&thread, NULL, thread_function, (void*)message); if (result != 0) { fprintf(stderr, "pthread_create failed: %s\n", strerror(result)); exit(EXIT_FAILURE); } printf("Waiting for thread to finish...\n"); // 等待线程结束 result = pthread_join(thread, NULL); if (result != 0) { fprintf(stderr, "pthread_join failed: %s\n", strerror(result)); exit(EXIT_FAILURE); } printf("Thread finished\n"); return 0;}
在编译上述代码时,需要链接 pthread 库,例如使用gcc -o thread_example thread_example.c -pthread命令进行编译 ,其中-pthread选项会正确设置必要的宏定义和链接库。在这个示例中,主线程创建了一个新线程,并传递了一个字符串参数给新线程的执行函数,新线程打印出这个字符串后退出,主线程通过pthread_join等待新线程结束 。
5.4创建线程时的属性设置有哪些?
在 Linux 中使用 POSIX 线程库(pthread 库)创建线程时,可以通过pthread_attr_t结构体来设置线程的属性 ,该结构体定义如下:
typedef struct{ int detachstate; // 线程的分离状态 int schedpolicy; // 线程调度策略 struct sched_param schedparam; // 线程的调度参数 int inheritsched; // 线程的继承性 int scope; // 线程的作用域 size_t guardsize; // 线程栈末尾的警戒缓冲区大小 int stackaddr_set; // 线程的栈设置 void* stackaddr; // 线程栈的位置 size_t stacksize; // 线程栈的大小} pthread_attr_t;
常见的线程属性设置如下:
栈大小(stacksize):默认情况下,线程会使用系统默认的栈大小,在不同的系统和编译器设置中,默认栈大小通常为 2 - 10MB 不等 。可以使用pthread_attr_setstacksize函数来设置线程栈的大小,例如:
#include <pthread.h>#include <stdio.h>intmain(){ pthread_attr_t attr; size_t stack_size = 4 * 1024 * 1024; // 设置为4MB pthread_attr_init(&attr); pthread_attr_setstacksize(&attr, stack_size); // 创建线程时使用attr属性 // pthread_create(..., &attr,...); size_t actual_stack_size; pthread_attr_getstacksize(&attr, &actual_stack_size); printf("实际设置的栈大小为 %zu 字节\n", actual_stack_size); pthread_attr_destroy(&attr); return 0;}
如果线程需要使用大量的局部变量或者函数调用层次很深,可能需要增大栈大小以防止栈溢出;而在创建大量线程时,为了避免内存耗尽,可以适当减小每个线程的栈大小 。
调度策略(schedpolicy):主要包括以下几种 :
- SCHED_OTHER:正常、非实时调度策略,这是默认的调度策略,通常采用 CFS(Completely Fair Scheduler,完全公平调度器)算法 。CFS 算法为每个进程维护一个虚拟运行时(vruntime),调度器总是选择 vruntime 最小的进程运行,以此来实现公平调度 。在这种调度策略下,线程的优先级由系统动态分配,适用于大多数普通的应用场景。
- SCHED_RR:实时、轮转法调度策略,它会给每个线程分配一个固定的时间片,当线程的时间片用完后,会被放到就绪队列的末尾,等待下一次调度 。例如,有三个实时线程 A、B、C,它们的时间片都为 10ms,当线程 A 运行完 10ms 后,即使它还没有完成任务,也会被暂停,将 CPU 资源让给线程 B,线程 B 运行 10ms 后再让给线程 C,如此循环。这种调度策略保证了相同优先级的实时线程能够公平地获取 CPU 时间,适用于对时间敏感且需要公平调度的实时任务。
- SCHED_FIFO:实时、先入先出调度策略,线程会按照进入就绪队列的先后顺序依次执行,只要当前运行的线程不主动放弃 CPU(例如调用pthread_yield函数主动让出 CPU,或者被更高优先级的线程抢占),它就会一直运行下去 。例如,在一个工业自动化控制系统中,某些控制任务需要立即执行且不能被打断,就可以使用SCHED_FIFO调度策略,确保这些关键任务能够优先得到处理。后两种实时调度策略(SCHED_RR和SCHED_FIFO)仅对超级用户有效,因为它们可能会影响系统的整体公平性和稳定性 。可以使用pthread_attr_setschedpolicy函数来设置调度策略 。
分离状态(detachstate):决定线程以何种方式终止 。
- PTHREAD_CREATE_DETACHED:分离状态启动,处于分离状态的线程在结束时,系统会自动回收其资源,不需要其他线程调用pthread_join来等待它结束并回收资源 。例如,在一个日志记录线程中,它的任务就是不断地将日志信息写入文件,当它完成任务后,不需要主线程或其他线程来关心它的结束状态,就可以将其设置为分离状态 。一旦设置为分离状态,就不能再调用pthread_join来等待该线程结束 。
- PTHREAD_CREATE_JOINABLE:聚合状态启动,这是默认状态,线程结束后,其资源不会立即释放,需要其他线程调用pthread_join函数来获取其退出状态并释放资源 。例如,在一个多线程计算任务中,主线程创建了多个子线程来执行计算,主线程需要知道每个子线程的计算结果,这时就需要将子线程设置为非分离状态,以便主线程通过pthread_join来等待子线程完成计算并获取结果 。可以使用pthread_attr_setdetachstate函数来设置分离状态 。
调度参数(schedparam):目前仅有一个sched_priority整型变量表示线程的运行优先级 ,这个参数仅当调度策略为实时(即SCHED_RR或SCHED_FIFO)时才有效 。可以使用pthread_attr_setschedparam函数来设置优先级 ,大的权值对应高的优先级 ,系统支持的最大和最小的优先级值可以用函数sched_get_priority_max和sched_get_priority_min得到 。例如,在一个视频编码应用中,视频帧的处理线程可能需要设置较高的优先级,以确保视频编码的实时性和流畅性,而一些辅助线程(如日志记录线程)可以设置较低的优先级 。
继承性(inheritsched):决定调度的参数是从创建的进程中继承还是使用在schedpolicy和schedparam属性中显式设置的调度信息 。
- PTHREAD_INHERIT_SCHED:新的线程继承创建线程的策略和参数。
- PTHREAD_EXPLICIT_SCHED:新的线程继承策略和参数来自于schedpolicy和schedparam属性中显式设置的调度信息,这是默认值 。可以使用pthread_attr_setinheritsched函数来设置继承性 。
作用域(scope):表示线程间竞争资源的范围,POSIX 的标准中定义了两个值 :
- PTHREAD_SCOPE_SYSTEM:与系统中所有线程一起竞争资源 ,目前 LinuxThreads 仅实现了这一值。
- PTHREAD_SCOPE_PROCESS:仅与同进程中的线程竞争 CPU 。可以使用pthread_attr_setscope函数来设置作用域 。
在设置线程属性时,一般需要先调用pthread_attr_init函数初始化pthread_attr_t结构体,使用完后调用pthread_attr_destroy函数释放相关资源 。
5.5进程和线程的调度方式有何区别?
进程和线程的调度方式存在诸多区别:
调度单位:进程调度是以进程为单位进行调度,操作系统从就绪队列中选择一个进程,并将 CPU 资源分配给它运行 。例如,在 Linux 系统中,当有多个进程处于就绪状态时,内核调度器会根据一定的调度算法(如完全公平调度器 CFS)从这些进程中挑选一个投入运行 。而线程调度是以线程为单位,同一进程内的多个线程会竞争 CPU 资源,由线程调度器决定哪个线程获得 CPU 执行权 。比如在一个多线程的 Java 程序中,JVM 的线程调度器会管理各个线程的执行顺序。
调度开销:进程调度的开销相对较大。因为进程拥有独立的资源,包括独立的地址空间、文件描述符表等,在进行进程切换时,需要保存和恢复进程的上下文信息,如寄存器状态、内存管理信息(页表等)、打开的文件状态等 。例如,当从进程 A 切换到进程 B 时,需要将进程 A 的所有这些上下文信息保存起来,再将进程 B 的上下文信息加载到 CPU 中,这个过程涉及到大量的数据读写和状态切换,开销较大 。而线程调度的开销相对较小,由于线程共享进程的资源,在进行线程切换时,只需保存和恢复少量的上下文信息,主要是线程的栈指针和寄存器状态 。例如,在同一进程内从线程 1 切换到线程 2,因为它们共享进程的地址空间和其他资源,所以不需要进行地址空间的切换等复杂操作,开销明显小于进程切换。
调度策略:进程调度和线程调度都有多种调度策略,但具体实现和应用场景有所不同 。常见的进程调度策略有先来先服务(FCFS,First - Come, First - Served ),按照进程到达就绪队列的先后顺序进行调度,这种策略实现简单,但可能导致长进程阻塞短进程,比如一个计算密集型的长进程先到达就绪队列,那么后面到达的短进程可能需要等待很长时间才能得到执行 ;最短作业优先(SJF,Shortest Job First ),优先调度预计运行时间最短的进程,但该策略需要预先知道每个进程的运行时间,这在实际中往往很难做到 ;优先级调度,为每个进程分配一个优先级,根据优先级的高低来调度进程,高优先级进程优先执行,例如系统进程通常会被分配较高的优先级,以确保系统的关键服务能够及时得到处理 ;
时间片轮转(RR,Round Robin ),将 CPU 时间划分为固定大小的时间片,每个进程轮流在一个时间片内执行,当时间片用完后,进程被放回就绪队列末尾等待下一次调度,这种策略常用于分时系统,保证每个进程都能得到一定的 CPU 时间,提高系统的交互性 。常见的线程调度策略有先来先服务(FIFO,First - In, First - Out ),类似于进程调度的 FCFS,按照线程进入就绪队列的先后顺序进行调度 ;时间片轮转(RR),与进程调度的时间片轮转类似,每个线程轮流在一个时间片内执行 ;优先级调度,为线程分配优先级,高优先级线程优先执行 ;实时调度策略,如 SCHED_FIFO(先入先出实时调度)和 SCHED_RR(时间片轮转实时调度),用于对时间要求严格的实时任务,例如在工业控制系统中,一些控制任务需要在特定的时间内完成,就可以使用实时调度策略来保证任务的及时执行 。在 Linux 系统中,线程的调度策略可以通过 pthread_attr_setschedpolicy 函数进行设置 。
调度时机:进程调度的时机通常包括进程创建、进程终止、进程阻塞(如等待 I/O 操作完成、等待信号量等)、时间片用完等情况 。例如,当一个进程执行 I/O 操作时,它会进入阻塞状态,此时操作系统会调度其他就绪进程运行 。线程调度的时机除了上述类似情况外,还包括线程主动放弃 CPU(如调用pthread_yield函数主动让出 CPU )、线程等待同步原语(如互斥锁、条件变量等)等 。例如,当一个线程试图获取一个被其他线程持有的互斥锁时,它会进入等待状态,此时线程调度器会调度其他就绪线程运行 。
5.6进程间通信有哪些方式?
在 Linux 中,进程间通信(IPC,Inter - Process Communication)有多种方式,每种方式都有其特点和适用场景:
管道(Pipe)
- 匿名管道:是一种半双工的通信方式,数据只能单向流动,且只能在具有亲缘关系(如父子进程)的进程间使用 。它由内核维护一个临时缓冲区,通过pipe函数创建,返回两个文件描述符,一个用于读(fd[0]),一个用于写(fd[1]) 。例如,在父子进程通信中,父进程创建管道后调用fork函数,子进程会继承父进程的文件描述符,然后父子进程分别关闭不需要的读端或写端,就可以实现数据的单向传输 。匿名管道的优点是实现简单,缺点是功能有限,只能用于亲缘进程,且缓冲区大小有限 。
- 命名管道(FIFO):也是半双工通信,但允许无亲缘关系的进程间通信 。它在文件系统中有对应的文件名,通过mkfifo函数创建 。任何进程都可以通过文件名打开命名管道进行读写操作 。例如,在一个生产者 - 消费者模型中,生产者进程和消费者进程可以通过命名管道进行数据传递 。命名管道克服了匿名管道只能用于亲缘进程的限制,但同样存在缓冲区大小有限等问题 。
消息队列(Message Queue):进程间以消息的形式进行通信,消息队列是由消息的链表组成,存放在内核中并由消息队列标识符标识 。发送进程将消息添加到队列中,接收进程从队列中获取消息 。消息队列分为 System V 消息队列和 POSIX 消息队列 。消息队列的优点是可以实现异步通信,消息有类型和优先级之分,适合传递结构化的数据 。例如,在一个任务调度系统中,任务提交进程可以将任务消息发送到消息队列,任务执行进程从队列中获取任务消息并执行 。缺点是相比共享内存等方式,性能较低,因为消息的复制和解析会带来额外开销 。
信号量(Semaphore):本质上是一个计数器,主要用于进程间或同一进程内不同线程之间的同步,控制对共享资源的访问 。当一个进程要访问共享资源时,先获取信号量(将信号量的值减 1),如果信号量的值小于 0,则该进程阻塞;当进程访问完共享资源后,释放信号量(将信号量的值加 1) 。信号量分为 System V 信号量和 POSIX 信号量 。例如,在多个进程共享一块内存区域时,可以使用信号量来保证同一时间只有一个进程能访问该内存区域,避免数据冲突 。信号量的优点是能有效控制对共享资源的访问,但使用不当容易引发死锁 。
共享内存(Shared Memory):是最快的 IPC 方式,允许多个进程直接访问同一块内存区域 。它通过shmget函数创建共享内存段,然后使用shmat函数将共享内存段映射到进程的地址空间 。例如,在一个多进程的数据库缓存系统中,多个进程可以通过共享内存来共享数据库缓存数据,减少数据的重复读取和内存占用 。共享内存没有数据复制的开销,所以速度快,但它没有内置的同步机制,需要结合信号量等同步工具来保证数据的一致性和正确性,以避免竞态条件 。
套接字(Socket):不仅可以用于同一台计算机上的进程间通信,还能用于不同计算机之间的网络通信 。套接字分为流式套接字(TCP)和数据报套接字(UDP) 。TCP 是面向连接的,提供可靠的字节流服务,适用于对数据可靠性要求高的场景,如文件传输、远程登录等;UDP 是无连接的,提供不可靠的数据报服务,适用于对实时性要求高、能容忍少量数据丢失的场景,如视频直播、实时游戏等 。在本地通信中,还可以使用 Unix 域套接字(AF_UNIX),它比 TCP 更高效,因为它不需要经过网络协议栈,直接在本地进程间传递数据 。例如,一个分布式系统中的各个节点进程之间可以通过套接字进行通信,实现数据交换和远程调用 。
信号(Signal):是一种异步通知机制,用于通知进程发生了特定事件 。进程可以通过signal函数注册信号处理函数,当接收到相应信号时,会执行注册的处理函数 。常见的信号有SIGINT(中断信号,如按下 Ctrl + C)、SIGTERM(终止信号)、SIGCHLD(子进程状态改变信号)等 。信号的优点是轻量级,适合进程行为控制,如进程的异常处理、优雅关闭等 。但它传递的信息量少,主要用于通知事件的发生,无法传递大量数据 。
内存映射文件(Memory - Mapped File/mmap):将文件直接映射到内存中,允许多个进程访问同一文件内容 。通过mmap函数可以将文件映射到进程的地址空间,进程可以像访问内存一样访问文件,减少了 I/O 操作 。例如,在一个多进程的日志系统中,多个进程可以通过内存映射文件来共享日志文件,实现高效的日志写入和读取 。内存映射文件适合高频访问文件内容的场景,但同样需要注意进程同步问题,以确保数据的一致性 。
5.7线程间如何进行通信?
线程间通信可直接访问共享内存,因为同一进程内的线程共享进程的地址空间,可直接操作共享变量、数据结构等,但需注意同步问题,以避免竞态条件。常用的同步机制有:
- 互斥锁(Mutex):最基本的同步工具,用于保证同一时刻只有一个线程可以访问共享资源 。当一个线程获取到互斥锁后,其他线程若试图获取该锁,将被阻塞,直到持有锁的线程释放锁 。例如,在一个多线程的银行账户管理系统中,账户余额是共享资源,使用互斥锁可以确保在同一时间只有一个线程能够对账户余额进行修改操作,避免出现数据不一致的情况 。在 POSIX 线程库中,通过pthread_mutex_init函数初始化互斥锁,pthread_mutex_lock函数加锁,pthread_mutex_unlock函数解锁 ,pthread_mutex_destroy函数销毁互斥锁 。
- 条件变量(Condition Variable):通常与互斥锁配合使用,用于线程间的同步和协调 。一个线程可以在条件变量上等待,直到另一个线程通过pthread_cond_signal(唤醒一个等待线程)或pthread_cond_broadcast(唤醒所有等待线程)函数发出信号,通知条件已满足 。例如,在生产者 - 消费者模型中,消费者线程可以在条件变量上等待,直到生产者线程生产出数据并发出信号,消费者线程才被唤醒并开始消费数据 。使用时,线程先获取互斥锁,然后在条件不满足时调用pthread_cond_wait函数,该函数会自动释放互斥锁并使线程进入等待状态,当条件满足被唤醒时,会重新获取互斥锁 。
- 读写锁(Read - Write Lock):允许多个线程同时进行读操作,但只允许一个线程进行写操作 。当有线程进行写操作时,其他读线程和写线程都将被阻塞,直到写操作完成 。例如,在一个多线程的数据库查询系统中,大量的线程可能只是读取数据库中的数据,而很少有线程进行数据更新操作,这时使用读写锁可以提高系统的并发性能,允许多个读线程同时读取数据,而只有在进行数据更新时才会阻塞其他线程 。在 POSIX 线程库中,通过pthread_rwlock_init函数初始化读写锁,pthread_rwlock_rdlock函数获取读锁,pthread_rwlock_wrlock函数获取写锁,pthread_rwlock_unlock函数解锁,pthread_rwlock_destroy函数销毁读写锁 。
- 信号量(Semaphore):本质上是一个计数器,用于控制对共享资源的访问 。线程在访问共享资源前,需要先获取信号量(将信号量的值减 1),如果信号量的值小于 0,则线程被阻塞;当线程访问完共享资源后,释放信号量(将信号量的值加 1) 。例如,在一个多线程的文件系统中,假设有多个线程需要访问一个共享的文件描述符,但系统规定同一时间最多只能有 3 个线程同时访问该文件描述符,这时可以使用信号量来控制,将信号量初始值设为 3,每个线程在访问文件描述符前先获取信号量,访问完后释放信号量 。信号量分为无名信号量和命名信号量,在 POSIX 线程库中,无名信号量通过sem_init函数初始化,sem_wait函数获取信号量,sem_post函数释放信号量,sem_destroy函数销毁信号量;命名信号量通过sem_open函数打开或创建,sem_close函数关闭,sem_unlink函数删除 。
- 线程局部存储(TLS,Thread - Local Storage):虽然不是严格意义上的线程间通信方式,但它为每个线程提供了独立的存储空间,避免了线程间的数据冲突 。例如,在一个多线程的日志记录系统中,每个线程可能需要记录自己的日志信息,使用线程局部存储可以为每个线程分配独立的日志缓冲区,互不干扰 。在 POSIX 线程库中,通过pthread_key_create函数创建一个线程局部存储键,pthread_setspecific函数设置线程局部存储的值,pthread_getspecific函数获取线程局部存储的值,pthread_key_delete函数删除线程局部存储键 。
5.8进程和线程在生命周期上有什么区别?
进程的生命周期:进程的生命周期包括创建、运行、阻塞、终止等阶段 。当一个程序被启动时,操作系统会创建一个新进程,为其分配独立的资源,如内存空间、文件描述符表等 。在创建阶段,操作系统会初始化进程控制块(PCB,在 Linux 中用task_struct结构体表示),记录进程的各种信息 。进入运行阶段后,进程会竞争 CPU 资源,根据调度策略获得 CPU 执行权后开始执行代码 。如果进程需要等待某些资源(如 I/O 操作完成、等待信号量等),会进入阻塞状态,此时进程让出 CPU,暂停执行,直到等待的资源可用或事件发生 。例如,当进程进行磁盘读取操作时,由于磁盘 I/O 速度相对较慢,进程会进入阻塞状态,等待磁盘数据读取完成 。当进程完成任务或出现错误时,会进入终止阶段,操作系统会回收进程占用的资源,包括释放内存、关闭文件描述符等,然后销毁进程控制块 。例如,使用exit函数可以使进程正常终止 。
线程的生命周期:线程的生命周期依赖于其所属的进程 。线程在进程创建后,可以通过pthread_create函数等方式创建 。创建线程时,系统会为线程分配独立的栈空间等少量资源,线程共享进程的大部分资源,如地址空间、堆内存等 。线程创建后进入就绪状态,等待 CPU 调度 。当线程获得 CPU 执行权后进入运行状态,执行线程函数中的代码 。在运行过程中,线程可能会因为等待同步原语(如互斥锁、条件变量等)、调用pthread_yield函数主动让出 CPU 等原因进入阻塞状态 。例如,当一个线程试图获取一个被其他线程持有的互斥锁时,它会进入阻塞状态,直到获取到锁 。线程执行完线程函数中的代码后,会自然结束,或者通过调用pthread_exit函数等方式主动结束 。如果进程终止,那么进程内的所有线程也会随之终止 。例如,在一个 Java 程序中,当主线程结束时,如果没有设置守护线程,其他子线程也会随之结束 。
总结:进程的生命周期相对独立和完整,拥有自己独立的资源管理和生命周期控制 。而线程是进程的一部分,生命周期依赖于进程,共享进程的资源,其创建、执行和结束都在进程的环境中进行 。进程的创建和销毁开销较大,因为涉及到大量资源的分配和回收;线程的创建和销毁开销相对较小,主要是栈空间等少量资源的管理 。
5.9在实际开发中如何选择使用进程还是线程?
在实际开发中,选择使用进程还是线程需要综合考虑多个因素:
- 任务性质:如果是 CPU 密集型任务,如科学计算、数据分析、加密解密等,由于主要消耗 CPU 资源,线程在多核处理器上可以充分利用多核并行计算的优势,减少任务执行时间,所以更适合使用线程 。例如,在一个图像识别系统中,对图像进行特征提取和分析的任务属于 CPU 密集型,使用多线程可以加快处理速度 。但如果任务需要较高的隔离性,如不同服务之间需要完全隔离,避免相互干扰,像数据库服务、Web 服务器等,进程更合适,每个进程独立运行,一个进程的崩溃不会影响其他进程,提高了系统的稳定性 。
- 资源消耗:进程创建和销毁的开销较大,因为需要分配独立的内存空间、文件描述符等资源 。如果任务需要频繁创建和销毁执行单元,使用线程可以减少系统开销,提高效率 。例如,在一个网络服务器中,需要频繁处理大量的短连接请求,每个请求创建一个线程来处理,线程的创建和销毁开销相对较小,能快速响应请求 。而线程共享进程的资源,在资源使用上相对进程更节省,对于一些资源有限的场景,如嵌入式系统开发,可能更倾向于使用线程 。
- 数据共享与通信:如果任务之间需要频繁共享大量数据,线程由于共享进程的内存空间,通信更加方便高效,可以直接访问共享变量 。例如,在一个多线程的游戏开发中,多个线程需要共享游戏场景、角色等数据,使用线程可以简化数据共享和通信的过程 。但共享数据也带来了数据一致性和同步的问题,需要使用同步机制(如互斥锁、条件变量等)来避免竞态条件 。而进程间通信相对复杂,需要使用管道、消息队列、共享内存等 IPC 机制,适合任务之间数据独立性要求高,通信不频繁的场景 。
- 错误处理:进程之间相互独立,一个进程的崩溃不会直接影响其他进程,对于稳定性要求高的系统,如金融交易系统,使用进程可以更好地保证系统的可靠性 。而线程共享进程的内存空间,一个线程的崩溃可能导致整个进程崩溃,所以在使用线程时需要更加注意错误处理和线程安全性 。
- 编程复杂度:多线程编程需要处理线程同步和数据竞争等问题,代码的编写和调试相对复杂 。如果开发团队对多线程编程经验不足,可能会引入难以排查的错误 。在这种情况下,如果任务对隔离性要求不是特别高,可以考虑使用进程,进程编程相对简单,因为进程之间的资源隔离,不需要过多考虑同步问题 。但进
- 程间通信的复杂性可能会在一定程度上增加开发难度,需要根据具体情况权衡 。