在 Linux 编程的世界里,进程与线程是理解系统运行机制的基石。进程作为程序执行的一个实例,拥有独立的内存空间和资源,是系统进行资源分配和调度的基本单位。线程则是进程内的一个执行单元,它们共享进程的地址空间,能以更低的成本实现并发,从而提升程序的执行效率。
无论是开发高性能服务器程序,还是优化复杂的系统应用,深入理解进程与线程的创建、管理和调度,都是迈向 Linux 编程高手的必经之路。本文将带领你深入探索 Linux 中进程与线程的创建奥秘,通过详实的代码示例和解析,助你对这两个核心概念有更透彻的掌握。
进程和线程基础回顾
1.1 进程是什么?
进程,简单来说,就是程序的一次执行过程。当我们在 Linux 系统中运行一个程序,比如执行 ./a.out,系统就会为这个程序创建一个进程。从操作系统的角度看,进程是资源分配的基本单位,它拥有独立的内存空间,包括代码段、数据段、堆和栈。就像一个独立的小王国,进程有自己独立的“领地”和“资源”。
每个进程都有自己的进程控制块(PCB,在 Linux 内核中用 task_struct 结构体表示),里面记录了进程的各种信息,如进程 ID(PID)、进程状态、优先级、打开的文件描述符列表等。这些信息就像是进程的“身份证”和“档案”,操作系统通过它们来管理和调度进程。例如,当我们使用 ps 命令查看系统中的进程时,看到的信息就来源于此。
进程之间是相互隔离的,一个进程无法直接访问另一个进程的内存空间和资源,这保证了系统的稳定性和安全性。如果一个进程出现了内存越界等错误,不会影响到其他进程的正常运行。例如,当浏览器进程崩溃时,并不会导致音乐播放器进程也停止工作。
1.2 线程是什么?
线程是进程内的执行单元,也被称为轻量级进程。一个进程可以包含多个线程,这些线程共享进程的地址空间和资源,如代码段、数据段、堆以及打开的文件描述符等。如果把进程比作一个工厂,那么线程就是工厂里的不同生产线,它们共享工厂的场地、设备,但各自执行不同的任务。
线程有自己独立的栈空间,用于存储函数调用的局部变量、返回地址等信息。同时,线程还有自己的寄存器,用于保存线程执行时的上下文信息。当线程被调度执行时,CPU 会从该线程的寄存器中读取上下文信息,继续执行该线程的代码。
线程的创建和销毁开销相对较小,因为它们不需要像进程那样重新分配大量的系统资源。线程间的切换也比进程间的切换快很多,因为切换时不需要切换地址空间等大量资源。这使得线程在实现高并发和并行计算时具有很大优势。例如,在一个网络服务器程序中,使用多线程可以同时处理多个客户端的连接请求。
1.3 进程与线程的关系和区别
进程和线程是密切相关的,线程是进程的一部分,一个进程至少包含一个线程(主线程)。它们就像是大树和树枝的关系,进程是大树的主干,线程是从主干上生长出来的树枝。
从资源分配的角度看,进程是资源分配的单位,拥有独立的资源;而线程是 CPU 调度的单位,基本上不拥有系统资源,但可以共享所属进程的资源。
在通信方面,进程间通信相对复杂,需要使用专门的进程间通信(IPC)机制,如管道、消息队列、共享内存等。而线程间通信则相对简单,由于线程共享进程资源,它们可以直接通过全局变量等方式进行通信,但需要注意线程同步和互斥的问题。
在创建和销毁开销以及上下文切换开销方面,进程的开销都比线程大。这也是为什么在需要频繁创建和销毁执行单元以及进行上下文切换的场景下,线程比进程更有优势。
Linux 进程创建深度剖析
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>
int main(){
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 函数退出后,父进程才会被调度运行。如果子进程在调用 exec 或 exit 之前依赖于父进程的进一步动作,就可能会导致死锁。
vfork 函数适用于一些特定的场景,比如当子进程需要立即执行 exec 系列函数加载新的程序时。由于子进程不需要独立的地址空间来运行新程序,使用 vfork 可以避免不必要的地址空间复制,从而提高程序的执行效率。
2.3 clone 函数:灵活定制进程创建
clone 函数是 Linux 提供的一个更为灵活的进程创建函数,它允许用户对新创建进程的资源共享和行为进行更细致的控制。clone 函数的原型如下:
# include <sched.h>
# include <signal.h>
# include <unistd.h>
int clone(int (*fn)(void *), void *child_stack, int flags, void *arg, ...);
其中,fn 是新创建进程(或线程)要执行的函数;child_stack 是新进程(或线程)使用的栈空间;flags 是一组标志位,用于指定新进程的特性;arg 是传递给 fn 函数的参数。
clone 函数的灵活性主要体现在 flags 参数上。通过设置不同的标志位,我们可以实现不同程度的资源共享和进程特性定制。例如,如果设置 CLONE_VM 标志位,新创建的进程将与父进程共享虚拟内存空间,类似于线程的行为。
以下是一个使用 clone 函数创建一个轻量级进程(类似线程)的示例:
# include <stdio.h>
# include <sched.h>
# include <unistd.h>
# include <stdlib.h>
# include <sys/wait.h>
# define STACK_SIZE (1024 * 1024)
static int child_func(void *arg){
printf("这是子进程(轻量级),我的PID是 %d\n", getpid());
return 0;
}
int main(){
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 等标志位,新创建的进程与父进程共享了虚拟内存、文件系统、文件描述符和信号处理等资源,实现了轻量级进程的创建。这种方式在需要创建多个共享部分资源的执行单元时非常有用。
Linux 线程创建实战
3.1 pthread_create 函数:开启多线程之旅
在 Linux 环境下,线程的创建主要借助于 POSIX 线程库(pthread 库)中的 pthread_create 函数。
pthread_create 函数的原型如下:
# include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
thread:这是一个输出参数,类型为 pthread_t,用于存储新创建线程的标识符。
attr:该参数用于设置线程的属性,是一个指向 pthread_attr_t 结构体的指针。如果将其设置为 NULL,则表示使用默认的线程属性。
start_routine:这是一个函数指针,指向新线程要执行的函数。
arg:这是传递给 start_routine 函数的参数,类型为 void*。
接下来,通过一个简单的代码示例来看看 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;
}
int main(){
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 函数,它就是新线程要执行的函数。
- 在
main 函数中,定义了一个 pthread_t 类型的变量 thread,用于存储新线程的标识符。同时定义了一个 int 类型的变量 param,作为传递给线程函数的参数。
- 调用
pthread_create 函数创建线程。如果返回值不为 0,则表示线程创建失败。
- 主线程继续执行。
- 最后调用
pthread_join 函数等待线程结束。通过 pthread_join 函数,主线程会阻塞,直到指定的线程执行完毕。
3.2 线程属性设置:个性化你的线程
在 Linux 线程编程中,线程属性的设置为我们提供了极大的灵活性,使我们能够根据具体的应用需求来定制线程的行为。
(1)设置线程栈大小
线程栈是线程执行函数时用于存储局部变量、函数调用栈等信息的内存区域。默认情况下,线程栈大小由系统或库的默认值决定,但在某些场景下,我们可能需要调整线程栈大小。
设置线程栈大小可以使用 pthread_attr_setstacksize 函数。下面是一个设置线程栈大小的示例代码:
# 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;
}
int main(){
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 函数,设置线程栈大小为 1MB。
- 然后使用设置了属性的
attr 来创建线程。
- 线程执行完毕后,调用
pthread_attr_destroy 函数销毁线程属性对象。
如果线程栈设置过小,当线程中需要使用较多的栈空间时,可能会导致栈溢出错误;而设置过大的线程栈,则会浪费内存资源。
(2)设置线程分离属性
线程的分离属性决定了线程结束时的资源回收方式。在默认情况下,线程是非分离状态,这种情况下,线程结束后,其线程 ID 和退出状态会被保留,直到其他线程调用 pthread_join 函数来回收资源。而分离状态的线程在结束时,会自动释放所有资源,无需其他线程等待。
设置线程分离属性可以使用 pthread_attr_setdetachstate 函数。
以下是一个设置线程分离属性的示例代码:
# include <stdio.h>
# include <pthread.h>
// 线程执行的函数
void* thread_function(void* arg){
printf("子线程开始执行\n");
sleep(1); // 模拟线程执行任务
printf("子线程执行结束\n");
return NULL;
}
int main(){
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 函数等待该线程结束。
进程与线程创建的常见问题及解决方案
4.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;
}
int main(){
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,从而避免了资源竞争问题。
信号量则是一个更通用的同步工具,它通过一个计数器来控制对共享资源的访问。在 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;
int main(){
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 线程安全问题
线程安全是多线程编程中一个至关重要的概念,它关乎着程序在多线程环境下的正确性和稳定性。简单来说,线程安全是指当多个线程访问某个代码块或共享资源时,不会出现数据不一致或程序逻辑混乱的情况。
在实际的多线程编程中,存在许多常见的线程不安全场景。对全局变量或静态变量的访问是一个典型的例子。由于多个线程共享进程的地址空间,它们都可以访问和修改这些变量。如果没有适当的同步机制,当多个线程同时对它们进行读写操作时,就可能出现数据竞争问题。
函数状态随着被调用而发生变化的情况也可能导致线程不安全。一个函数内部维护了一些状态变量,当多个线程同时调用这个函数时,这些状态变量的变化可能会相互干扰。
为了避免线程安全问题,我们可以采取多种措施。最基本的方法是使用同步机制,如前面提到的互斥锁、信号量等,来保护共享资源的访问。在 C++11 中,引入了原子操作,它可以确保对共享数据的操作是原子性的,从而避免了数据竞争问题。使用 std::atomic<int> 类型来定义一个原子变量,对其进行自增操作时,就不需要额外的锁来保证线程安全。
# include <iostream>
# include <atomic>
# include <thread>
std::atomic<int> count(0);
void increment(){
for (int i = 0; i < 1000; ++i) {
count++;
}
}
int main(){
std::thread thread1(increment);
std::thread thread2(increment);
thread1.join();
thread2.join();
std::cout << "最终count的值为: " << count << std::endl;
return 0;
}
在这个示例中,std::atomic<int> 类型的 count 变量保证了自增操作的原子性,即使在多线程环境下也能正确执行。
尽量减少共享数据的范围也是一种有效的方法。如果能将数据限制在单个线程内部使用,就可以完全避免线程安全问题。可以使用线程局部存储,为每个线程提供独立的变量副本。在 C++ 中,可以使用 thread_local 关键字来定义线程局部变量。
# include <iostream>
# include <thread>
thread_local int local_data = 0;
void thread_function(){
for (int i = 0; i < 1000; ++i) {
local_data++;
}
std::cout << "线程中local_data的值为: " << local_data << std::endl;
}
int main(){
std::thread thread1(thread_function);
std::thread thread2(thread_function);
thread1.join();
thread2.join();
return 0;
}
在这个例子中,local_data 是一个线程局部变量,每个线程都有自己独立的副本,因此不存在线程安全问题。
4.3 内存管理问题
在进程和线程创建过程中,内存管理是一个不可忽视的重要环节,它直接关系到程序的稳定性、性能以及资源利用率。
堆内存的分配与释放是内存管理中的一个关键要点。在多线程环境下,这些操作需要特别小心。如果多个线程同时进行堆内存的分配和释放,可能会导致内存泄漏、悬空指针等问题。
为了避免这种情况,我们可以使用线程安全的内存分配和释放函数,或者在进行内存操作时使用同步机制来保护这些操作。在 C++ 中,可以使用智能指针(如 std::unique_ptr、std::shared_ptr)来管理堆内存,它们能够自动处理内存的释放,从而减少内存泄漏的风险。
# include <iostream>
# include <memory>
# include <thread>
// 使用std::shared_ptr管理共享资源
std::shared_ptr<int> shared_resource;
void thread_function(){
// 线程中使用共享资源
if (shared_resource) {
std::cout << "线程中访问共享资源的值: " << *shared_resource << std::endl;
}
}
int main(){
// 分配共享资源
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>
void recursive_function(int depth) {
int local_variable[1024]; // 占用一定栈空间
if (depth > 10000) {
return;
}
recursive_function(depth + 1);
}
int main() {
recursive_function(0);
return 0;
}
在这个示例中,recursive_function 函数每递归一次,就会在栈上分配 local_variable 数组的空间,当递归深度过大时,就可能导致栈溢出。为了防止栈溢出,我们可以合理设置线程栈大小,根据线程的实际需求来调整栈空间的大小;同时,在编写代码时,要注意避免不必要的深层递归调用。
进程和线程高频面试题
5.1 Linux 中创建进程的方式有哪些?
在 Linux 中,主要有以下三种创建进程的方式:
- 使用
fork 函数:fork 函数是创建新进程最常用的方式,它被调用一次,但会返回两次。在父进程中,返回值是新创建子进程的 PID;在子进程中,返回值是 0。子进程会复制父进程的几乎所有资源,包括内存空间(采用写时复制技术)、文件描述符、环境变量等。这使得子进程在创建初期与父进程非常相似,但它们是相互独立的进程。
- 使用
vfork 函数:vfork 函数也用于创建新进程,与 fork 函数有一些关键区别。vfork 创建的子进程与父进程共享地址空间,这意味着子进程对内存的修改会直接影响到父进程。在子进程调用 exec 系列函数或 exit 函数之前,父进程会被阻塞。vfork 主要用于子进程需要立即执行一个新程序的场景。
- 使用
clone 函数:clone 函数提供了比 fork 和 vfork 更细粒度的控制,它可以通过传入不同的标志位来指定子进程与父进程之间共享的资源。例如,如果设置 CLONE_VM 标志,子进程与父进程共享内存空间。clone 函数非常灵活,可以用于创建线程,也可以根据具体需求创建具有不同资源共享特性的轻量级进程。
5.2 使用 fork 创建进程的原理和步骤是什么?
使用 fork 创建进程的原理和步骤如下:
- 系统调用:当一个进程调用
fork 函数时,会触发一个系统调用,陷入内核态。
- 内核创建子进程的数据结构:内核为新的子进程创建一个进程控制块(PCB,在 Linux 内核中用
task_struct 结构体表示)。新的 task_struct 中的大部分信息会从父进程的 task_struct 复制而来。
- 内存资源处理(写时复制,COW):现代 Linux 系统采用写时复制技术来优化内存复制过程。子进程的页表会被创建,并且页表项初始时指向与父进程相同的物理内存页面。这些页面被标记为只读,当父进程或子进程尝试对这些共享页面进行写操作时,会触发一个页错误,内核才会为触发写操作的进程分配一个新的物理页面。
- 文件描述符复制:父进程的文件描述符表也会被复制到子进程中。这意味着子进程可以访问父进程打开的所有文件。
- 返回值处理:
fork 函数会返回两次,一次在父进程中,一次在子进程中。在父进程中,fork 返回子进程的 PID;在子进程中,fork 返回 0。如果 fork 调用失败,则在父进程中返回 -1。
5.3 在 Linux 中如何创建线程?
在 Linux 中,通常使用 POSIX 线程库(pthread 库)来创建线程,主要使用 pthread_create 函数,其函数原型为:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
thread:指向 pthread_t 类型变量的指针,用来存储新创建线程的线程 ID。
attr:指向 pthread_attr_t 类型的结构体指针,用于设置线程的属性。如果设置为 NULL,则表示使用默认的线程属性。
start_routine:这是一个函数指针,指向线程开始执行时要调用的函数。
arg:传递给 start_routine 函数的参数。
函数调用成功时返回 0;如果失败,会返回一个非零的错误码。
5.4 创建线程时的属性设置有哪些?
在 Linux 中使用 POSIX 线程库创建线程时,可以通过 pthread_attr_t 结构体来设置线程的属性,常见的属性包括:
- 栈大小(stacksize):可以使用
pthread_attr_setstacksize 函数来设置线程栈的大小。
- 调度策略(schedpolicy):主要包括
SCHED_OTHER(默认,正常非实时调度)、SCHED_RR(实时轮转法)、SCHED_FIFO(实时先入先出)。
- 分离状态(detachstate):
PTHREAD_CREATE_JOINABLE(默认,聚合状态)或 PTHREAD_CREATE_DETACHED(分离状态)。
- 调度参数(schedparam):用于设置线程的实时优先级。
- 继承性(inheritsched):决定调度的参数是从创建的进程中继承还是使用显式设置的调度信息。
- 作用域(scope):表示线程间竞争资源的范围。
在设置线程属性时,一般需要先调用 pthread_attr_init 函数初始化 pthread_attr_t 结构体,使用完后调用 pthread_attr_destroy 函数释放相关资源。
5.5 进程和线程的调度方式有何区别?
进程和线程的调度方式存在诸多区别:
- 调度单位:进程调度是以进程为单位进行调度;而线程调度是以线程为单位。
- 调度开销:进程调度的开销相对较大,因为进程切换时需要保存和恢复进程的上下文信息;而线程调度的开销相对较小,只需保存和恢复少量的上下文信息。
- 调度策略:两者都有多种调度策略,但具体实现和应用场景有所不同。常见的进程调度策略有先来先服务、最短作业优先、优先级调度、时间片轮转等。常见的线程调度策略有先来先服务、时间片轮转、优先级调度、实时调度策略等。
- 调度时机:进程调度的时机通常包括进程创建、终止、阻塞、时间片用完等;线程调度的时机除了上述类似情况外,还包括线程主动放弃 CPU、等待同步原语等。
5.6 进程间通信有哪些方式?
在 Linux 中,进程间通信(IPC)有多种方式:
- 管道(Pipe):包括匿名管道(用于亲缘进程)和命名管道(FIFO,可用于无亲缘关系进程)。
- 消息队列(Message Queue):进程间以消息的形式进行通信,存放在内核中。
- 信号量(Semaphore):本质上是一个计数器,主要用于进程间或线程间的同步。
- 共享内存(Shared Memory):是最快的 IPC 方式,允许多个进程直接访问同一块内存区域。
- 套接字(Socket):可用于同一台计算机或不同计算机之间的进程通信。
- 信号(Signal):是一种异步通知机制,用于通知进程发生了特定事件。
- 内存映射文件(Memory-Mapped File):将文件直接映射到内存中,允许多个进程访问同一文件内容。
5.7 线程间如何进行通信?
线程间通信可直接访问共享内存,因为同一进程内的线程共享进程的地址空间,可直接操作共享变量、数据结构等,但需注意同步问题。常用的同步机制有:
- 互斥锁(Mutex):用于保证同一时刻只有一个线程可以访问共享资源。
- 条件变量(Condition Variable):通常与互斥锁配合使用,用于线程间的同步和协调。
- 读写锁(Read-Write Lock):允许多个线程同时进行读操作,但只允许一个线程进行写操作。
- 信号量(Semaphore):本质上是一个计数器,用于控制对共享资源的访问。
- 线程局部存储(TLS):为每个线程提供了独立的存储空间,避免了线程间的数据冲突。
5.8 进程和线程在生命周期上有什么区别?
- 进程的生命周期:包括创建、运行、阻塞、终止等阶段。进程的生命周期相对独立和完整,拥有自己独立的资源管理和生命周期控制。
- 线程的生命周期:依赖于其所属的进程。线程在进程创建后创建,共享进程的大部分资源,其创建、执行和结束都在进程的环境中进行。
进程的创建和销毁开销较大;线程的创建和销毁开销相对较小。
5.9 在实际开发中如何选择使用进程还是线程?
在实际开发中,选择使用进程还是线程需要综合考虑多个因素:
- 任务性质:CPU 密集型任务更适合使用线程以利用多核优势;需要高隔离性的服务更适合使用进程。
- 资源消耗:需要频繁创建销毁执行单元时,线程开销更小。
- 数据共享与通信:需要频繁共享大量数据时,线程通信更高效;进程间通信相对复杂。
- 错误处理:进程之间相互独立,一个进程的崩溃不会直接影响其他进程,稳定性更高。
- 编程复杂度:多线程编程需要处理同步问题,相对复杂;进程编程相对简单,但进程间通信可能增加复杂度。
深入理解 操作系统 层面的进程与线程机制,并结合具体的 多线程 应用场景进行权衡,是做出正确技术选型的关键。如果你想深入探讨更多系统编程或网络相关的技术细节,欢迎到 云栈社区 与更多开发者交流。