Linux之Kernel(1)系统基础理论(2)
Author: Once Day Date: 2025年2月10日
一位热衷于Linux学习和开发的菜鸟,试图谱写一场冒险之旅,也许终点只是一场白日梦…
漫漫长路,有人对你微笑过嘛…
全系列文章可参考专栏: Linux内核知识_Once-Day的博客-CSDN博客
参考文章:
文章目录
1. 用户空间进程
1.1 进程介绍
进程是操作系统中一个非常重要的概念。简单来说,进程就是一个正在运行的程序实例。当我们在计算机上启动一个程序时,操作系统就会创建一个进程,分配必要的系统资源,如CPU时间、内存空间等,来支持该程序的运行。每个进程都有自己独立的地址空间,包含程序代码、数据、堆栈等。
从操作系统的角度来看,进程是资源分配和调度的基本单位。操作系统通过对进程的管理和调度,合理有效地分配和利用系统资源,使多个程序可以并发执行,从而提高系统的整体性能。不同进程之间一般是相互独立的,互不干扰。但有时候多个进程之间也需要通信和同步,操作系统提供了一些机制,如管道、消息队列等,来满足这些需求。
进程具有以下一些主要特征:
- 动态性:进程是程序的一次执行过程,是动态产生、变化和消亡的。
- 独立性:进程实体之间在时间和空间上是相互独立的。
- 并发性:多个进程实体同存于内存中,能在一段时间内同时运行。不同进程并发执行,相互之间不会影响。
- 异步性:进程按各自独立的、不可预知的速度向前推进,操作系统使用多种机制来实现进程的异步性,例如时间片轮转调度算法。
进程有多种状态,如运行态、就绪态、阻塞态等。进程在其生命周期内,在不同状态间转换,这些状态转换受操作系统控制。例如,当进程等待某个事件(如I/O操作完成)发生时,会从运行态转为阻塞态;当该事件发生时,又会从阻塞态转为就绪态,等待操作系统的调度。
进程管理是操作系统的核心功能之一。操作系统需要记录和维护与进程相关的各种信息,如进程状态、资源占用情况等,称为进程控制块(PCB)。通过对进程控制块的管理,实现进程的创建、撤销、状态转换等各种操作。
1.2 进程空间分配
当一个程序被加载到内存并成为进程时,操作系统会为该进程分配独立的内存空间,这就是进程映像(Process Image)。进程映像包含了程序执行所需的所有信息,主要分为以下几个部分:
- 代码段(Code Segment/Text Segment):存储程序的可执行指令。这部分内存通常是只读的,以防止指令被意外修改。代码段的大小在程序运行前就已确定。有时,代码段中也可能包含一些只读的常量,如字符串常量等。
- 数据段(Data Segment):存储已初始化的全局变量和静态(static)变量。这部分内存是可读可写的,因为变量的值可能在运行时被修改。数据段的大小也在程序运行前确定。数据段中的数据生存期与进程同步,即在进程创建时分配,进程终止时回收。
- BSS段(BSS Segment):存储未初始化的全局变量和静态变量。与数据段不同,BSS段中的变量默认值为0。BSS段也是可读可写的,其生存期与进程同步。
- 栈(Stack):存储函数调用时的局部变量(非静态变量)、函数参数、返回地址等。每个线程都有自己的栈空间。栈的内存是可读可写的,其生存期与函数同步,即在函数调用时分配,函数返回时自动回收。
- 堆(Heap):存储程序运行时动态分配的内存,如使用malloc()、realloc()等函数分配的内存。应用程序可以在运行时根据需要从堆中分配和释放内存。堆的生存期与进程同步,即在进程创建时分配,进程终止时回收。
进程映像中的这些内存段按照一定的顺序排列,通常是代码段、数据段、BSS段、堆、栈。不同的操作系统和编译器可能采用稍有不同的内存布局。
1.3 进程状态和转换
进程有五种基本状态:
- 运行态:进程正在CPU上运行,已获得CPU和其他所需资源。
- 就绪态:进程已具备运行条件,但由于没有空闲CPU,暂时不能运行。处于就绪态的进程通常排成一个队列,称为就绪队列。
- 阻塞态(又称等待态):进程因等待某一事件(如I/O操作完成)而暂时不能运行。处于阻塞态的进程也会排成队列,称为阻塞队列。
- 创建态:进程正在被创建,操作系统正在为其分配资源、初始化PCB,尚未转入就绪态。
- 终止态:进程正在从系统中撤销,操作系统会回收其资源,撤销PCB。
进程状态转换是通过原语(Primitive)实现的,主要有以下几种:
- 进程创建:使用创建原语,将进程从创建态转为就绪态。创建原语会申请PCB,分配资源,初始化PCB,并将PCB插入就绪队列。
- 进程终止:使用撤销原语,将进程从就绪态/阻塞态/运行态转为终止态,最后撤销进程。撤销原语会终止子进程,回收资源,删除PCB。
- 进程阻塞:使用阻塞原语,将进程从运行态转为阻塞态。阻塞原语会保存进程运行现场,更新PCB状态,将PCB插入相应的事件等待队列。
- 进程唤醒:使用唤醒原语,将进程从阻塞态转为就绪态。唤醒原语会在事件等待队列中找到PCB,更新其状态,并将其插入就绪队列。
- 进程切换:使用切换原语,将进程从运行态转为就绪态,或从就绪态转为运行态。切换原语会保存旧进程运行环境,选择新进程,恢复其运行环境。
引起进程状态转换的事件包括:
- 进程创建:用户登录、作业调度、提供服务、应用请求等。
- 进程终止:正常结束、异常结束、外界干预等。
- 进程阻塞:等待系统资源、等待其他进程完成工作等。
- 进程唤醒:等待的事件发生。
- 进程切换:时间片用完、有更高优先级的进程到达、进程主动阻塞或终止等。
1.4 进程启动和退出
进程的启动与退出都需要通过内核。内核通过调用exec函数来调用进程的main函数,从而启动进程。进程的正常终止方式是调用exit函数(间接调_exit
)或直接调用_exit
。
进程的异常退出有两种情况:
- 向进程发送信号导致其异常退出。
- 代码错误导致进程运行时异常退出。异常处理函数实际上是通过向挂起进程发送信号,进而通过信号的默认处理程序终止进程运行。
init进程(pid=1)是Linux内核在建立进程概念时通过kernel_thread产生的第一个用户态进程(idle进程除外)。init进程开始在内核态执行,然后通过系统调用,开始执行用户空间的/sbin/init
程序。/sbin/init
可能会产生shell,之后所有的用户进程都由该进程派生出来。因此,init进程被称为"进程之父"。
父子进程的调度顺序是不确定的,子进程会继承父进程的环境、堆栈、文件描述符、信号控制设定、调度方式等。子进程不会继承父进程的线程、定时器、信号状态。
除了正常运行结束,进程结束的直接原因都是信号。常见的异常退出信号有SIGABRT、SIGFPE、SIGSEGV等。
当进程收到某些信号时,默认的信号处理函数会在终止进程之前对进程的内存映像进行存储,形成当前时刻的"快照",即Core dumped。并非所有信号都会导致Core dumped。Core dumped文件的位置可以通过cat /proc/sys/kernel/core_pattern
查看。
如果一个进程结束了,但其父进程没有等待(调用wait/waitpid
)它,那么它将变成一个僵尸进程。僵尸进程会泄露进程资源,因此需要避免。父进程可以通过wait和waitpid等函数等待子进程结束,也可以用signal函数为SIGCHLD注册handler,在handler中调用wait回收。如果父进程通过signal(SIGCHLD, SIG_IGN)通知内核自己对子进程的结束不感兴趣,那么子进程结束后,内核会自动回收。
1.5 进程间通信
进程间通信(IPC, Inter-Process Communication)主要如下。
- 信号量(Semaphore),信号量是一种计数器,用于控制多个进程对共享资源的访问。通过PV操作(P操作申请资源,V操作释放资源)来实现进程间的同步与互斥。信号量适用于控制对临界区的访问,防止竞态条件的发生。
- 共享内存(Shared Memory),共享内存允许多个进程访问同一块内存区域,是最快的IPC方式。一个进程创建了共享内存,其他进程可以访问这块内存。共享内存适用于需要频繁交换大量数据的场景,如客户端-服务器系统。使用共享内存需要自己实现同步机制,如信号量,以避免竞态条件。
- 消息队列(Message Queue),消息队列是一个消息的链表,存放在内核中。一个进程往队列中写入消息,另一个进程从队列中读取消息。消息队列提供了一种异步的通信方式,发送者和接收者不需要同时与队列交互。消息队列适用于在不同进程间传递数据,特别是在客户端-服务器和分布式系统中。
- 管道(Pipe),管道提供了一种简单的IPC方式,通常用于在父子进程间通信。管道有两种:匿名管道和命名管道。匿名管道只能用于具有亲缘关系的进程间通信,命名管道可以用于任意进程间通信。管道是半双工的,数据只能单向流动。如果需要双向通信,需要创建两个管道。
- 套接字(Socket),套接字是一种通用的IPC机制,可以用于不同主机上的进程间通信。套接字基于网络协议(如TCP/IP)实现,提供了可靠的、双向的、点对点的通信通道。套接字广泛用于网络编程,是实现分布式系统和网络应用的基础。
- 文件(File),文件也可以用于进程间通信,多个进程可以访问同一个文件。一个进程写入文件,另一个进程读取文件,实现数据的共享。使用文件进行IPC的优点是简单直观,缺点是效率较低,且需要自己实现同步机制。
- 信号(Signal),信号是一种软件中断,用于通知进程发生了某个事件。一个进程可以发送信号给另一个进程,接收进程可以选择如何处理该信号(忽略、默认处理或自定义处理)。信号适用于异步事件的通知,如终止进程、暂停进程、通知I/O事件等。
选择合适的IPC方式取决于具体的应用场景,如通信的频率、数据量的大小、是否需要同步、进程的关系(是否在同一主机上)等因素。
信号量、共享内存,消息队列对比:
Interface接口 | Semaphores信号量 | Shared memory共享内存 | Message Queue消息队列 |
---|---|---|---|
Header file | < semaphore.h > | < sys/mman.h > | < mqueue.h > |
Object handle对象句柄 | sem_t * | int (文件句柄) | mqd_t |
Create/open创建打开 | sem_open() | shm_open() + ftruncate() + mmap() | mq_open() |
Close关闭 | sem_close() | munmap() + close() | mq_close |
Unlink断开 | sem_unlink() | shm_unlink() | mq_unlink |
Perform IPC执行IPC | sem_post(), sem_wait(), sem_getvalue() | 直接在共享内存区域中进行操作 | mq_receive() mq_send() |
1.6 进程信号处理
信号类似于中断,可用于异步通信。信号可来自硬件(如硬件异常)和软件(如其他进程发送的信号)。
信号是不可靠的,信号可能会丢失。当一个非实时信号诞生后,如果发现相同的信号已经在目标结构中注册,则不再注册,对进程而言,相当于不知道本次信号发生,导致信号丢失。
只有当进程的未决信号中没有相同信号时,新信号才会在进程中注册。
信号的处理方式:
- 忽略,进程可以选择忽略某些信号(除了SIGKILL和SIGSTOP)。
- 捕捉,进程可以使用sigaction或signal函数为信号注册处理函数,当信号发生时,执行相应的处理函数。
- 默认,大部分信号的默认动作是终止进程。
- 屏蔽,进程可以使用sigprocmask函数暂时屏蔽某些信号,被屏蔽的信号不会被递达,直到解除屏蔽。
特殊的SIGKILL和SIGSTOP信号不可被忽略、捕捉或屏蔽。
信号处理函数位于当前线程的上下文中,而不是另一个线程。信号被捕捉到时,进程正在执行的指令序列会被信号处理程序临时中断。无法判断捕捉到信号时进程正在执行何处的代码。信号处理函数及其调用的函数中不能使用静态数据结构,因为信号可能在任何时候发生,可能会破坏数据的一致性。errno是每个线程共用的,因此在信号处理函数的入口处应保存errno的值,并在退出时恢复。
为了避免信号处理函数中的竞态条件,信号处理函数应该是可重入的(reentrant)。可重入函数应满足:
- 不在函数内部使用静态或全局数据。
- 不返回静态或全局数据,所有数据都由函数的调用者提供。
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据。
- 不调用不可重入函数。
2. 用户空间线程
2.1 线程介绍
线程是进程中的一个实体,是操作系统独立调度和分派的基本单位,也是利用CPU的基本单位。一个进程中的所有线程共享该进程的资源,包括内存空间、文件描述符等。线程可以并发执行,提高程序的执行效率。
进程在运行时,默认会有一个主线程。如果在线程中直接调用exit函数,会导致整个进程退出,而不仅仅是该线程退出。如果主线程运行结束,而其他线程未运行结束,整个进程也会结束。
线程间共享地址空间,使得编程更加简单。在线程间共享数据时,不需要像进程间通信那样使用特殊的IPC机制。
使用线程可以将任务并行化,提高程序的执行效率。将一个大任务分解为多个小任务,分配给多个线程并发执行,可以更好地利用系统资源。
POSIX线程(Pthreads)是一种标准化的线程API,在Unix/Linux系统中广泛使用。每个线程都有一个线程ID(pthread_t),在进程内是唯一的。使用pthread_create函数创建新线程,新线程的调度顺序由内核决定,不能做任何假设。
线程的终止方式有三种:
- 线程从启动例程(即线程函数)中返回。
- 线程被同一进程中的其他线程取消。
- 线程调用pthread_exit函数。
线程的其他相关函数:
pthread_join
,等待一个线程的结束。pthread_detach
,将一个线程设置为分离状态,线程结束后会自动释放资源。pthread_cancel
,取消(结束)一个线程。pthread_mutex_*
,用于线程间的互斥同步。pthread_cond_*
,用于线程间的条件同步。
使用线程可以提高程序的并发度和执行效率,但也要小心线程安全问题。
2.2 分离线程和非分离线程
在POSIX线程中,线程有两种状态:joinable(可汇合的)和detached(分离的)。这两种状态决定了线程结束后,其资源如何被回收。
(1)分离线程(Detached Thread),在结束时会自动释放其占用的资源,如线程栈空间等。其他线程无法通过pthread_join函数等待分离线程的结束和获取其返回值。
适用场景:当我们不关心线程的结束状态,只需要线程执行指定的任务即可。
(2)非分离线程(Joinable Thread),非分离线程在结束后,其资源(如栈)不会立即被释放,需要其他线程调用pthread_join函数来回收。其他线程可以通过pthread_join函数等待非分离线程的结束,并获取其返回值。
适用场景:当我们需要知道线程的结束状态,或者需要线程返回某个计算结果时。
需要注意,对于已经结束或不存在的线程调用pthread_join,按照POSIX标准,pthread_join的retval参数应该返回一个特定值,表示线程不存在或已结束。但在某些系统中,对已结束或不存在的线程调用pthread_join可能会导致进程终止。
如果线程既没有被detach,也没有被其他线程join,这种情况下,线程结束后,其资源不会被释放,从而导致资源泄漏。为避免资源泄漏,应确保每个线程要么被detach,要么被其他线程join。
进程的主函数(main函数)本身也被内核视为一个线程,称为主线程。在内核中,每个线程都对应一个task_struct结构,用于描述线程的属性和状态。内核将进程和POSIX线程都作为任务(task)进行管理,调度器负责决定哪个任务(进程或线程)在CPU上运行。
2.3 函数的线程安全性
并不是所有的函数都是线程安全的,在多线程编程时需要特别注意这一点。
非线程安全函数的特点:
- 使用了多个线程共享的全局变量或静态变量。
- 函数中调用了其他非线程安全的函数。
解决非线程安全问题的方法:
- 使用互斥量(mutex)对共享资源进行保护。
- 避免使用全局变量或静态变量,将函数改写为可重入函数(reentrant function)。
- 使用线程特定数据(Thread-Specific Data)技术,为每个线程分配独立的资源。
互斥量(Mutex)是一种用于保护共享资源的同步机制,它能够确保同一时间只有一个线程访问共享资源。
- 在访问共享资源前,必须先对互斥量进行加锁(lock)操作。
- 互斥量最多只能有一个所有者(加锁者)。
- 只有互斥量的所有者才能对其进行解锁(unlock)操作。
- 如果试图对一个已经被加锁的互斥量再次加锁,线程将被挂起或直接返回错误。
- 当多个线程等待同一个互斥量解锁时,不能假设哪个线程会最先获得锁。
临界资源与临界区:
- 临界资源,每次仅允许一个线程访问的资源,如打印机、全局共享变量、共享缓冲区等。
- 临界区,线程中访问临界资源的代码片段,该代码片段的执行必须是原子性的。
互斥量的粒度:
- 将每个共享变量对应一个互斥量可能导致互斥量过多,容易引起死锁。
- 将所有共享变量对应一个互斥量可能导致临界区过大,互斥粒度太粗。
- 需要根据具体情况,选择合适的互斥粒度,避免死锁和性能问题。
死锁(Deadlock)是指两个或多个线程因竞争资源而造成的一种互相等待的现象。死锁发生的四个必要条件:
- 资源互斥使用,每个资源要么已经分配给了一个线程,要么就是可用的。
- 请求并保持,一个线程因请求资源而阻塞时,对已获得的资源保持不放。
- 不可剥夺,已经分配给一个线程的资源不能强制性地被抢占,只能由线程自己释放。
- 循环等待,若干线程之间形成一种头尾相接的循环等待资源关系。
避免死锁的常用方法:
- 按照固定的顺序获取锁。
- 在尝试获取锁时,设置一个超时时间,超时后放弃对锁的请求。
- 资源按层次分类,按层次顺序申请资源。
条件变量(Condition Variable)是另一种常用的线程同步机制,它允许线程在某个条件未满足时等待,避免了忙等(busy-waiting)的情况。条件变量的主要操作:
wait
:阻塞当前线程,直到收到一个通知(signal或broadcast)。signal
:唤醒至少一个等待该条件变量的线程。broadcast
:唤醒所有等待该条件变量的线程。
使用条件变量时需要注意以下几点:
- 条件变量没有保持状态信息,如果没有线程在等待,通知(signal/broadcast)将会丢失。
- pthread_cond_wait()调用必须放在while循环而不是if语句中,因为在被唤醒后需要重新检查条件是否满足。
- 条件变量必须与互斥量一起使用,以避免竞态条件。
3. POSIX定时器
POSIX定时器是一种用于在指定时间后执行某个动作的机制。它提供了比传统的Unix信号更灵活、更精确的定时功能。POSIX定时器可以用于周期性地执行某个任务,或者在指定时间后只执行一次。
使用POSIX定时器一般涉及以下步骤:
- 创建一个定时器(timer_create)。
- 设置定时器的属性,如定时器类型、初始到期时间、间隔时间等(timer_settime)。
- 指定定时器到期时要执行的动作,如发送信号、执行回调函数等。
- 启动定时器(timer_settime)。
- 在不再需要定时器时,删除定时器(timer_delete)。
以下是与POSIX定时器相关的主要API函数:
#include <time.h>
#include <signal.h>
int timer_create(clockid_t clockid, struct sigevent *sevp, timer_t *timerid);
int timer_settime(timer_t timerid, int flags, const struct itimerspec *new_value, struct itimerspec *old_value);
int timer_gettime(timer_t timerid, struct itimerspec *curr_value);
int timer_delete(timer_t timerid);
timer_create
: 创建一个新的定时器,返回定时器ID。timer_settime
: 设置定时器的到期时间和间隔时间,并启动定时器。timer_gettime
: 获取定时器的当前剩余时间。timer_delete
: 删除指定的定时器。
以下是一个简单的POSIX定时器示例,演示了如何创建一个定时器,并在定时器到期时发送信号:
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <time.h>
void timer_handler(int sig) {
printf("Timer expired!\n");
}
int main() {
timer_t timerid;
struct sigevent sev;
struct itimerspec its;
// 设置定时器到期时要发送的信号
sev.sigev_notify = SIGEV_SIGNAL;
sev.sigev_signo = SIGUSR1;
signal(SIGUSR1, timer_handler);
// 创建定时器
timer_create(CLOCK_REALTIME, &sev, &timerid);
// 设置定时器的初始到期时间和间隔时间
its.it_value.tv_sec = 5;
its.it_value.tv_nsec = 0;
its.it_interval.tv_sec = 0;
its.it_interval.tv_nsec = 0;
// 启动定时器
timer_settime(timerid, 0, &its, NULL);
// 等待定时器到期
pause();
// 删除定时器
timer_delete(timerid);
return 0;
}
在这个示例中,创建了一个定时器,并设置它在5秒后到期。当定时器到期时,会发送SIGUSR1信号,然后执行事先注册的信号处理函数timer_handler
。
Once Day
也信美人终作土,不堪幽梦太匆匆......
如果这篇文章为您带来了帮助或启发,不妨点个赞👍和关注,再加上一个小小的收藏⭐!
(。◕‿◕。)感谢您的阅读与支持~~~