操作系统篇

进程和线程的区别?

  1. 一个线程只能属于一个进程,而一个进程可以有多个线程,但至少有一个线程。线程依赖于进程而存在。
  2. 进程在执行过程中拥有独立的内存单元,而多个线程共享进程的内存。(资源分配给进程,同一进程的所有线程共享该进程的所有资源。同一进程中的多个线程共享代码段(代码和常量),数据段(全局变量和静态变量),扩展段(堆存储)。但是每个线程拥有自己的栈段,栈段又叫运行时段,用来存放所有局部变量和临时变量。)
  3. 进程是资源分配的最小单位,线程是CPU调度的最小单位;
  4. 系统开销: 由于在创建或撤消进程时,系统都要为之分配或回收资源,如内存空间、I/o设备等。因此,操作系统所付出的开销将显著地大于在创建或撤消线程时的开销。类似地,在进行进程切换时,涉及到整个当前进程CPU环境的保存以及新被调度运行的进程的CPU环境的设置。而线程切换只须保存和设置少量寄存器的内容,并不涉及存储器管理方面的操作。可见,进程切换的开销也远大于线程切换的开销。
  5. 通信:由于同一进程中的多个线程具有相同的地址空间,致使它们之间的同步和通信的实现,也变得比较容易。进程间通信IPC,线程间可以直接读写进程数据段(如全局变量)来进行通信——需要进程同步和互斥手段的辅助,以保证数据的一致性。在有的系统中,线程的切换、同步和通信都无须操作系统内核的干预。
  6. 进程编程调试简单可靠性高,但是创建销毁开销大;线程正相反,开销小,切换速度快,但是编程调试相对复杂。
  7. 进程间不会相互影响 ;线程一个线程挂掉将导致整个进程挂掉。
  8. 进程适应于多核、多机分布;线程适用于多核。
  9. 线程必须放在fork之后,因为fork之前创建的线程不会被继承.

在 Linux 内核中,0 号进程(也称为 idle 进程)是系统中唯一一个没有被正常进程显式创建的进程,它是在内核启动时创建的,负责消耗 CPU 时间,以保证系统不会浪费 CPU 资源。
1 号进程(也称为 init 进程)是 Linux 系统启动后第一个被创建的进程,它是所有进程的祖先进程。init 进程负责启动系统上其他进程和服务,并在系统关闭时关闭这些进程和服务。
2 号进程(也称为 kthreadd 进程)是一个守护进程,它是内核中的一个主要线程。它是内核线程的创建者,也是唯一的内核线程,其主要职责是协调内核中的各种子系统,如内存管理、进程管理、文件系统等。
需要注意的是,0 号进程、1 号进程和 2 号进程是内核级别的进程,它们不同于普通进程,不会被用户显式创建、销毁或操纵。

进程间通信的方式

进程间通信主要包括管道、系统IPC(包括消息队列、信号量、信号、共享内存等)、以及套接字socket。

管道

管道主要包括匿名管道和命名管道:匿名管道可用于具有亲缘关系的父子进程间的通信,命名管道除了具有管道所具有的功能外,它还允许无亲缘关系进程间的通信

  1. 匿名管道PIPE
    • 它是半双工的(即数据只能在一个方向上流动),具有固定的读端和写端
    • 它只能用于具有亲缘关系的进程之间的通信(也是父子进程或者兄弟进程之间)
    • 它可以看成是一种特殊的文件,对于它的读写也可以使用普通的read、write等函数。但是它不是普通的文件,并不属于其他任何文件系统,并且只存在于内存中。
  2. 命名管道FIFO
    • FIFO可以在无关的进程之间交换数据
    • FIFO有路径名与之相关联,它以一种特殊设备文件形式存在于文件系统中

系统IPC

  1. 消息队列
    消息队列,是消息的链接表,存放在内核中。一个消息队列由一个标识符(即队列ID)来标记。 (消息队列克服了信号传递信息少,管道只能承载无格式字节流以及缓冲区大小受限等特点)具有写权限得进程可以按照一定得规则向消息队列中添加新信息;对消息队列有读权限得进程则可以从消息队列中读取信息;
  • 消息队列是面向记录的,其中的消息具有特定的格式以及特定的优先级。
  • 消息队列独立于发送与接收进程。进程终止时,消息队列及其内容并不会被删除。
  • 消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取。
  1. 信号量semaphore
    信号量(semaphore)与已经介绍过的 IPC 结构不同,它是一个计数器,可以用来控制多个进程对共享资源的访问。信号量用于实现进程间的互斥与同步,而不是用于存储进程间通信数据。
  • 信号量用于进程间同步,若要在进程间传递数据需要结合共享内存。
  • 信号量基于操作系统的 PV 操作,程序对信号量的操作都是原子操作。
  • 每次对信号量的 PV 操作不仅限于对信号量值加 1 或减 1,而且可以加减任意正整数。
  • 支持信号量组。
  1. 信号signal
    信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。

  2. 共享内存(Shared Memory)
    它使得多个进程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存中数据得更新。这种方式需要依靠某种同步操作,如互斥锁和信号量等

  • 共享内存是最快的一种IPC,因为进程是直接对内存进行存取
  • 因为多个进程可以同时操作,所以需要进行同步
  • 信号量+共享内存通常结合在一起使用,信号量用来同步对共享内存的访问

套接字SOCKET

socket也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同主机之间的进程通信。
线程间通信的方式:

  • 临界区:通过多线程的串行化来访问公共资源或一段代码,速度快,适合控制数据访问;
  • 互斥量Synchronized/Lock:采用互斥对象机制,只有拥有互斥对象的线程才有访问公共资源的权限。因为互斥对象只有一个,所以可以保证公共资源不会被多个线程同时访问
  • 信号量Semphare:为控制具有有限数量的用户资源而设计的,它允许多个线程在同一时刻去访问同一个资源,但一般需要限制同一时刻访问此资源的最大线程数目。
  • 事件(信号),Wait/Notify:通过通知操作的方式来保持多线程同步,还可以方便的实现多线程优先级的比较操作

死锁

产生死锁的原因主要是

  1. 竞争资源:多个进程同时竞争有限的资源,例如CPU、内存、磁盘。每个进程都需要多个资源才能继续执行,而同时又不想放弃已经获得的资源。
  2. 非可剥夺资源:某些资源是非可剥夺的,即一旦被某个进程占用,就不能被其他进程抢占。例如一些硬件资源如打印机。
  3. 循环等待:每个进程都在等待其他进程释放资源,并同时占有其他进程所需要的资源。例如进程P1持有资源R1,请求R2资源,而进程P2占有资源R2,请求R1资源,两个进程都在等待另一个进程释放资源,导致产生死锁。
  4. 互斥条件:某些资源只能被一个进程占用,在其他进程占用时需要等待该资源被释放后才能使用。例如,某个进程正在访问磁盘上的某个文件,其他进程必须等待该进程释放该文件才能访问它。

解决方案

  1. 防止循环等待:可以使用资源排序的技术来避免循环等待的情况。例如,可以给每个资源分配一个编号,进程的请求按照这些编号的顺序来进行申请,避免多个进程之间形成死循环的情况。
    • 正例:线程A、B申请资源顺序均为R1->R2;
    • 反例:A申请顺序为R1->R2,B申请顺序为R2->R1。
  2. 超时等待:可以给进程分配一定的资源,如果在一定时间内还不能满足它的资源需求,就释放它已经占有的资源,等待其他进程释放资源后再次请求。如果资源请求超时,就会中止进程并释放资源,避免死锁的产生。

进程的内存分布

进程的内存分布一般分为四个部分,它们分别是:

  1. 代码段(text segment):也叫为只读区(read-only section),存储着可执行的机器代码。在Linux系统中,代码段通常被映射到进程的虚拟地址空间的低地址部分。
  2. 数据段(data segment):也叫为已初始化区(initialized data section),存储着程序中已经明确定义了初值的全局变量、静态变量或static变量。在Linux系统中,数据段通常被映射到进程的虚拟地址空间的低地址部分,并且数据段紧挨着代码段。
  3. BSS段:也叫为未初始化区(uninitialized data section),存储着程序中未经初始化的全局变量、静态变量或static变量。在Linux系统中,BSS段通常被映射到进程的虚拟地址空间的低地址部分,并且BSS段的大小在程序运行前就已经确定。
  4. 堆(heap):堆是动态分配的内存,由程序员使用调用堆管理函数(如malloc)并手工释放(如free)来管理。堆在进程的虚拟地址空间中生长,向高地址方向增加。
  5. 栈(stack):栈是系统自动分配和释放的存储程序临时变量的区域。每个函数会在栈中分配自己的空间,该空间在函数退出时会被自动释放。栈是向低地址方向生长的,通常被映射到虚拟地址空间的高地址部分。

基于以上解释,当进程被加载到内存当前,它的代码和只读数据被加载到虚拟地址空间的低端。已初始化数据紧跟在代码之后、未初始化数据(BSS)则会紧跟在已初始化数据(data segment)之后。这些部分共同占用了进程虚拟地址空间的低端。堆是存放动态分配内存的区域,由低地址向高地址扩展。栈的生长方向是向低地址方向,从高地址向低地址扩展。当进程运行时,所有函数的局部变量都是被分配在栈中的,函数随堆栈指针回退而销毁。

可能存在的额外段:

  1. 动态链接库段(dynamic libraries segment): ELF(可执行和链接格式)文件格式是一种用于共享库和可执行的Linux二进制文件格式。在使用动态库的情况下,程序运行时会加载动态链接库。动态链接库也是共享的,所以它们在进程的地址空间中是单独的段,而不是存储在代码段或数据段中。
  2. 内存映射文件段(memory-mapped files segment): Linux支持将文件映射到内存以提高文件I/O性能。在这种情况下,映射的文件会被映射到进程虚拟地址空间的单独段中。

虚拟地址是什么,怎么映射成物理地址

在操作系统中,虚拟地址是指进程所见到的地址空间,它是逻辑上由进程使用的地址,与实际在物理内存中存放的地址不一定相同。简而言之,虚拟地址其实就是进程所使用的地址,提供了一种独立于物理内存地址的编址方式。

为了让进程所使用的虚拟地址能够映射到实际的物理地址,操作系统会在进程运行时调度内存管理单元(Memory Management Unit, MMU)来实现虚拟地址到物理地址的映射。在这一过程中,操作系统将虚拟地址划分为页(Page)并与物理内存上相应的页框(Page Frame)进行映射,使得进程在使用虚拟地址的同时能够正确地找到相应的物理内存地址。

虚拟地址的实现通常会通过地址转换逻辑和替换算法来映射到物理内存中对应的地址。由于每次访问内存时必须查找页表,这将会导致大量的访问延迟。为了解决这个问题,MMU会使用快表(Translation Lookaside Buffer,简称TLB)来提高转换效率。快表是一个硬件高速缓存,记录了最近使用的部分虚拟页到物理页的映射关系。因为快表对应于内存中的物理地址,因此查找快表只需要一次内存访问,相比访问页表要快很多。

当CPU访问一个虚拟页时,MMU会查找对应的物理页框。如果相应的物理页框在物理内存中不存在,那么MMU就会引起一个缺页中断,并将处理权交还给操作系统。操作系统通过从磁盘中读入页数据,然后更新页表中的物理地址,最后将控制权返回给进程,让进程重新执行该指令。
缺页中断的原因主要有两个方面:

  1. 内存不足:当系统中的内存不够时,操作系统无法将所有的页面都加载到内存中,就会出现缺页中断。这种情况下,操作系统需要根据一定的算法来决定哪些页面应该优先加载到内存中,以保证程序能够正常运行。
  2. 页面置换:即使内存足够,也会出现缺页中断的情况,因为系统中的某些页面可能会被替换出去,以为新的页面腾出空间。当程序尝试访问一个已经被替换出去的页面时,就会触发缺页中断,操作系统则需要将该页面重新加载到内存中。

中断

它是一种异步机制,允许计算机停止当前进程,去执行一个已经被预定义好的处理程序或服务例程,再回到原进程继续执行。中断可以发生在任何时间,包括当计算机在运行进程时,键盘输入数据时,鼠标点击时,网络通信时,甚至是硬件故障时等等。
常见的中断类型包括以下几种:

  • 定时器中断(Timer Interrupts):由计算机的定时器硬件产生的中断,用于定期运行操作系统的任务和服务,例如系统更新、进程调度、日志记录等。
  • 外部设备中断(External Interrupts):由计算机外部设备传输的信号产生的中断,例如硬盘读写完成、网络数据传输完成、键盘输入等。
  • 异常中断(Exception Interrupts):由程序执行中出现的异常事件所产生的中断,例如除零错误、内存访问错误等。
  • 软件中断(Software Interrupts):由计算机内部程序指令所产生的中断,可以由应用程序主动发起以请求操作系统提供服务等。

链接

在链接阶段,链接器需要做以下工作:

  • 符号解析和重定位(Symbol Resolution and Relocation):将目标文件中的符号(symbol)解析为具体的地址,并进行合适的重定位(relocation)。
  1. 合并代码段和数据段(Code and Data Segment Merging):将多个目标文件的代码段和数据段合并为一个整体,消除段之间的重叠。
  2. 对于静态库或动态库,还需要将库文件(.lib 或 .a)或共享库文件(.dll 或 .so)中的符号链接到目标文件中。

perf

  1. 使用 Perf 分析程序:执行 perf record 命令将程序运行事件和相关的 CPU 性能数据记录到数据文件中。例如:perf record -p 命令可以监测指定 PID 进程的性能情况。
  2. 分析 Perf 收集的数据:执行 perf report 命令来分析 Perf 收集的数据,并生成图形化的分析报告。例如: perf report 命令可以生成图形化的火焰图以展示函数调用时间。
  3. 使用 Perf 命令行工具:Perf 提供了多个命令行选项,可以通过这些选项对收集的性能数据进行各种操作。例如:perf stat 命令可以显示指定进程的 CPU 使用率、缓存命中率等。
  4. perf top 是一个 Linux 下性能分析工具 perf 的一个命令,用于显示系统中正在运行的进程或线程的 CPU 使用率和系统调用。perf top 会按照 CPU 时间使用情况进行排序,让用户可以看到占用 CPU 时间最多的进程或函数,从而帮助用户找到系统性能瓶颈。
1
2
3
4
5
git clone https://github.com/brendangregg/FlameGraph.git
# 其中,-F参数指定采样频率,-g参数生成函数调用关系,-p参数指定要分析的进程的进程ID。
perf record -F 99 -g -p <pid>
perf record -g ./testperf
perf script | ~/FlameGraph/stackcollapse-perf.pl | ~/FlameGraph/flamegraph.pl > perf.svg


信号

在Linux系统中,可以通过调用sigprocmask函数来阻塞或解除阻塞某些信号。阻塞某些信号表示当前进程或线程不会接收这些信号。当进程或线程接收到被阻塞的信号时,该信号被添加到未决信号集合中,直到该信号被解除阻塞,可以使用sigpending()函数查询当前进程的未决信号集,未决信号是指已经向进程发送过,但尚未被进程处理的信号。当一个信号被发送给一个进程时,内核会把信号放入进程的未决信号集合中,表示该信号已经被发送给进程但尚未被处理。进程在处理完当前信号后会检查未决信号集合中是否还有其他未处理的信号,如果有则继续处理,否则就继续执行下一条指令。

阻塞某些信号通常用于保护关键代码段,以防止由于信号的干扰而导致的意外中断。在阻塞信号的期间,可以安全地访问共享数据结构,而无需担心被信号处理程序修改。

当关键代码段完成后,可以解除对信号的阻塞,以便信号处理程序可以及时地响应。解除信号阻塞通常用于恢复正常信号处理程序的行为,例如在进程收到SIGINT信号(即中断信号)时,可以捕获该信号并执行清理操作,最后调用exit()函数终止进程。

如下展示了如何阻塞SIGINT信号,等待一段时间后再解除阻塞,使得程序能够响应SIGINT信号。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <signal.h>
#include <stdio.h>
#include <unistd.h>

int main() {
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGINT);
sigprocmask(SIG_BLOCK, &set, NULL);
printf("Blocking SIGINT...\n");
sleep(5);
printf("Unblocking SIGINT...\n");
sigprocmask(SIG_UNBLOCK, &set, NULL);
while(1) {
// do some work here
}
return 0;
}

signalfd 可以只处理特定的信号事件,具体可以通过使用 sigprocmask() 函数来限制需要处理的信号集合。例如,可以使用 sigprocmask() 函数限制 signalfd 只接收 SIGINT 和 SIGTERM 信号:
sigprocmask函数只能用于单线程,在多线程中使用pthread_sigmask函数。如果有多线程都有信号屏蔽字,需要在创建线程之前设置屏蔽字,新创建的线程就会继承。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGTERM); // signal函数就不会处理了
if (sigprocmask(SIG_BLOCK, &mask, nullptr) == -1) {
perror("sigprocmask");
exit(EXIT_FAILURE);
}

int sfd = signalfd(-1, &mask, 0);
if (sfd == -1) {
perror("signalfd");
exit(EXIT_FAILURE);
}

如果你同时使用 signal 函数和 signalfd 函数,那么需要注意以下几点:

  1. 在使用 signalfd 函数前需要先屏蔽要监视的信号,否则信号会被默认处理,程序无法接收到信号并进行处理。可以使用 sigprocmask 函数屏蔽信号。
  2. 使用 signalfd 后,需要使用 poll 或者 select 等系统调用等待信号事件的发生,而不是使用 signal 函数等待信号的到来。
  3. 使用 signal 函数时,需要将信号处理函数重置回默认处理函数。否则信号将一直被 signal 函数处理,不会传递给 signalfd。可以使用 signal 函数将信号处理函数设置为 SIG_DFL。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    /**********************主线程******************/
    // 主线程创建signalfd是否成功
    sigset_t mask;
    sigemptyset(&mask);
    sigaddset(&mask, SIGINT);
    sigaddset(&mask, SIGQUIT);
    sigaddset(&mask, SIGTERM);
    sfd = signalfd(-1, &mask, 0);
    if (-1 == sfd)
    {
    printf("signalfd error %d\n", errno);
    return false;
    }
    return true;
    // 如果signalfd成功,设置阻塞信号,这一步在创建其它子线程之前
    sigset_t mask;
    sigemptyset(&mask);
    sigaddset(&mask, SIGINT);
    sigaddset(&mask, SIGQUIT);
    sigaddset(&mask, SIGTERM);
    if (-1 == pthread_sigmask(SIG_BLOCK, &mask, nullptr))
    {
    printf("pthread_sigmask error %d\n", errno);
    }
    /**********************主线程******************/

    /**********************子线程******************/
    // 创建子线程,如果signalfd成功,处理sfd
    // 设置默认处理函数
    signal(SIGINT, SIG_DFL);
    signal(SIGTERM, SIG_DFL);
    signal(SIGQUIT, SIG_DFL);

    // 使用 poll 等待信号事件
    struct pollfd fds[1];
    fds[0].fd = sfd;
    fds[0].events = POLLIN;

    if (sfd >= 0)
    {
    int result = poll(fds, 1, timeout);
    if (result == -1)
    {
    usleep(timeout * 1000);
    }
    else if (result > 0)
    {
    // 有信号事件发生
    if (fds[0].revents & POLLIN) {
    struct signalfd_siginfo info;
    read(sfd, &info, sizeof(info));
    int ssiPid = info.ssi_pid;
    char buf[256] = {0};
    char name[256] = {0};
    snprintf(buf, sizeof(buf), "/proc/%d/exe", ssiPid);
    readlink(buf, name, 256);
    string ssiExeName = name;
    printf("PID of sender: %d, name: %s", ssiPid, ssiExeName.c_str());
    }
    }
    }
    /**********************子线程******************/

还可以用sigaction实现。

1
2
3
4
5
6
7
8
9
void signal_handler(int signum, siginfo_t* siginfo, void* context) {
printf("Received signal %d from process %d\n", signum, siginfo->si_pid);
}
// 注册信号处理函数
struct sigaction act;
memset(&act, 0, sizeof(act));
act.sa_sigaction = signal_handler;
act.sa_flags = SA_SIGINFO;
sigaction(SIGTERM, &act, NULL);

调用 sigsuspend 会临时将进程的信号屏蔽字替换为 mask 指向的信号集,并挂起进程,直到收到一个未被阻塞的信号或者一个处理程序被调用。如果在挂起期间收到一个被阻塞的信号,则该信号仍然被阻塞。如果收到一个未被阻塞的信号,且信号处理程序不是默认操作(即不是终止进程),则处理程序将运行,然后 sigsuspend 返回,进程的信号屏蔽字会恢复为调用 sigsuspend 前的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

void handler(int sig) {
printf("Received signal %d\n", sig);
}

int main() {
struct sigaction sa;
sa.sa_handler = handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;

sigaction(SIGINT, &sa, NULL);

printf("Waiting for SIGINT...\n");

sigset_t sigset;
sigset_t preSigset;
sigemptyset(&sigset);
sigemptyset(&preSigset);
sigaddset(&sigset, SIGINT);

// 将SIGINT加入到信号屏蔽字中
sigprocmask(SIG_BLOCK, &sigset, &preSigset);

// 处理一些事情
// ...
sleep(2);

#if 0
sigprocmask(SIG_UNBLOCK, &sigset, NULL); // sigsuspend相当于这两个的原子操作
pause();
#else
// 使用sigsuspend等待信号
sigsuspend(&preSigset);
#endif

printf("Done\n");

return 0;
}

// $ ./testsuspend
// Waiting for SIGINT...
// ^CReceived signal 2

// ^CReceived signal 2
// Done

// 修改后

// $ ./testsuspend
// Waiting for SIGINT...
// ^CReceived signal 2
// Done

另外,signal函数是进程进程级别的,也就是说,如果子线程重新设置了signal,会把主线程的handler覆盖掉。

内存泄漏的定义,如何检测与避免?
定义:内存泄漏简单的说就是申请了一块内存空间,使用完毕后没有释放掉。 它的一般表现方式是程序运行时间越长,占用内存越多,最终用尽全部内存,整个系统崩溃。由程序申请的一块内存,且没有任何一个指针指向它,那么这块内存就泄漏了。
如何检测内存泄漏
首先可以通过观察猜测是否可能发生内存泄漏,Linux 中使用 swap 命令观察还有多少可用的交换空间,在一两分钟内键入该命令三到四次,看看可用的交换区是否在减少。
还可以使用 其他一些 /usr/bin/stat 工具如 netstat、vmstat 等。如发现波段有内存被分配且从不释放,一个可能的解释就是有个进程出现了内存泄漏。
当然也有用于内存调试,内存泄漏检测以及性能分析的软件开发工具 valgrind 这样的工具来进行内存泄漏的检测。

动态编译与静态编译

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
$ echo | gcc -dM -E - | grep __aarch64__
$ ls /lib/ld-linux-armhf.so.3 查看libc库类型
$ ldd cache-node 查看依赖库
# linux-vdso.so.1 (0xbef29000)
# libpthread.so.0 => /lib/arm-linux-gnueabihf/libpthread.so.0 (0xb6ee7000)
# libdl.so.2 => /lib/arm-linux-gnueabihf/libdl.so.2 (0xb6ee4000)
# librt.so.1 => /lib/arm-linux-gnueabihf/librt.so.1 (0xb6ee1000)
# libstdc++.so.6 => /lib/arm-linux-gnueabihf/libstdc++.so.6 (0xb6d60000)
# libm.so.6 => /lib/arm-linux-gnueabihf/libm.so.6 (0xb6d1f000)
# libgcc_s.so.1 => /lib/arm-linux-gnueabihf/libgcc_s.so.1 (0xb6ce0000)
# libc.so.6 => /lib/arm-linux-gnueabihf/libc.so.6 (0xb6bc9000)
# /lib/ld-linux-armhf.so.3 (0xb6eef000)
# 以下是每个库的作用说明:
#
# linux-vdso.so.1
#
# 作用: VDSO(Virtual Dynamic Shared Object)是一个由内核提供的共享库,帮助优化某些系统调用的性能。它不是真正的文件,而是内核提供的内存映射。
# libpthread.so.0
#
# 路径: /lib/arm-linux-gnueabihf/libpthread.so.0
# 作用: POSIX 线程库,提供多线程编程支持。用于创建和管理线程。
# libdl.so.2
#
# 路径: /lib/arm-linux-gnueabihf/libdl.so.2
# 作用: 动态链接库,提供动态加载和卸载共享库的功能。允许程序在运行时加载和使用其他共享库。
# librt.so.1
#
# 路径: /lib/arm-linux-gnueabihf/librt.so.1
# 作用: 实时库,提供与实时操作系统相关的扩展功能,如高精度定时器和信号量。
# libstdc++.so.6
#
# 路径: /lib/arm-linux-gnueabihf/libstdc++.so.6
# 作用: 标准 C++ 库,提供 C++ 标准库的实现,包括数据结构、算法、字符串处理、输入输出流等。
# libm.so.6
#
# 路径: /lib/arm-linux-gnueabihf/libm.so.6
# 作用: 数学库,提供常见的数学函数,如三角函数、指数、对数等。
# libgcc_s.so.1
#
# 路径: /lib/arm-linux-gnueabihf/libgcc_s.so.1
# 作用: GCC 运行时库,提供一些低级支持功能,如异常处理和算术操作。由 GCC 编译器生成的代码所需要。
# libc.so.6
#
# 路径: /lib/arm-linux-gnueabihf/libc.so.6
# 作用: 标准 C 库,提供 C 标准库函数的实现,如字符串操作、内存管理、输入输出等。
# /lib/ld-linux-armhf.so.3

作用: 动态链接器/加载器,负责在程序启动时加载和链接共享库。在 ARM 硬浮点 ABI 系统上用于加载其他共享库。

  1. 静态编译,编译器在编译可执行文件时,把需要用到的对应动态链接库中的部分提取出来,连接到可执行文件中去,使可执行文件在运行时不需要依赖于动态链接库,静态编译时不需要链接对应的依赖库,编译时可加-static区分动态库;
  2. 动态编译,可执行文件需要附带一个动态链接库,在执行时,需要调用其对应动态链接库的命令。所以其优点一方面是缩小了执行文件本身的体积,另一方面是加快了编译速度,节省了系统资源。缺点是哪怕是很简单的程序,只用到了链接库的一两条命令,也需要附带一个相对庞大的链接库;二是如果其他计算机上没有安装对应的运行库,则用动态编译的可执行文件就不能运行,加-Wl,-rpath指定运行库的位置。pmap查看进程动态加载的库。ldd查看文件已经依赖的库,nm可以查看符号表。

为什么需要线程?
线程产生的原因:进程可以使多个程序能并发执行,以提高资源的利用率和系统的吞吐量;但是其具有一些缺点:
进程在同一时刻只能做一个任务,很多时候不能充分利用CPU资源。
进程在执行的过程中如果发生阻塞,整个进程就会挂起,即使进程中其它任务不依赖于等待的资源,进程仍会被阻塞。
引入线程就是为了解决以上进程的不足,线程具有以下的优点:
从资源上来讲,开辟一个线程所需要的资源要远小于一个进程。
从切换效率上来讲,运行于一个进程中的多个线程,它们之间使用相同的地址空间,而且线程间彼此切换所需时间也远远小于进程间切换所需要的时间(这种时间的差异主要由于缓存的大量未命中导致)。
从通信机制上来讲,线程间方便的通信机制。对不同进程来说,它们具有独立的地址空间,要进行数据的传递只能通过进程间通信的方式进行。线程则不然,属于同一个进程的不同线程之间共享同一地址空间,所以一个线程的数据可以被其它线程感知,线程间可以直接读写进程数据段(如全局变量)来进行通信(需要一些同步措施)。

进程和线程的基本API

进程API以Unix系统为例,线程相关的API属于Posix线程(Pthreads)标准接口。
| 进程原语 | 线程原语 | 描述 |
| —— | ——————- | ————————–|
| fork | pthread_create | 创建新的控制流 |
| exit | pthread_exit | 从现有的控制流中退出 |
| waitpid | pthread_join | 从控制流中得到退出状态 |
| atexit | pthread_cancel_push | 注册在退出控制流时调用的函数 |
| getpid | pthread_self | 获取控制流的ID |
| abort | pthread_cancel | 请求控制流的非正常退出 |

进程调度的时机
当前运行的进程运行结束。
当前运行的进程由于某种原因阻塞。
执行完系统调用等系统程序后返回用户进程。
在使用抢占调度的系统中,具有更高优先级的进程就绪时。
分时系统中,分给当前进程的时间片用完。

常见的页面置换算法
当访问一个内存中不存在的页,并且内存已满,则需要从内存中调出一个页或将数据送至磁盘对换区,替换一个页,这种现象叫做缺页置换。当前操作系统最常采用的缺页置换算法如下:

  1. 先进先出(FIFO)算法:
    思路:置换最先调入内存的页面,即置换在内存中驻留时间最久的页面。
    实现:按照进入内存的先后次序排列成队列,从队尾进入,从队首删除。
    特点:实现简单;性能较差,调走的页面可能是经常访问的
  2. 最近最少使用(LRU)算法:
    思路: 置换最近一段时间以来最长时间未访问过的页面。根据程序局部性原理,刚被访问的页面,可能马上又要被访问;而较长时间内没有被访问的页面,可能最近不会被访问。
    实现:缺页时,计算内存中每个逻辑页面的上一次访问时间,选择上一次使用到当前时间最长的页面调走
    特点:可能达到最优的效果,维护这样的访问链表开销比较大
    当前最常采用的就是LRU算法。
  3. 最不常用算法(Least Frequently Used, LFU)
    思路:缺页时,置换访问次数最少的页面
    实现:每个页面设置一个访问计数,访问页面时,访问计数加1,缺页时,置换计数最小的页面
    特点:算法开销大,开始时频繁使用,但以后不使用的页面很难置换

简述线程和进程的区别和联系
一个线程只能属于一个进程,而一个进程可以有多个线程,但至少有一个线程。线程依赖于进程而存在。
进程在执行过程中拥有独立的地址空间,而多个线程共享进程的地址空间。(资源分配给进程,同一进程的所有线程共享该进程的所有资源。同一进程中的多个线程共享代码段(代码和常量),数据段(全局变量和静态变量),扩展段(堆存储)。但是每个线程拥有自己的栈段,栈段又叫运行时段,用来存放所有局部变量和临时变量。)
进程是资源分配的最小单位,线程是CPU调度的最小单位。
通信:由于同一进程中的多个线程具有相同的地址空间,使它们之间的同步和通信的实现,也变得比较容易。进程间通信IPC,线程间可以直接读写进程数据段(如全局变量)来进行通信(需要一些同步方法,以保证数据的一致性)。
进程编程调试简单可靠性高,但是创建销毁开销大;线程正相反,开销小,切换速度快,但是编程调试相对复杂。
进程间不会相互影响;一个进程内某个线程挂掉将导致整个进程挂掉。
进程适应于多核、多机分布;线程适用于多核。

为什么要引入虚拟内存?
虚拟内存作为缓存的工具

  1. 虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。
    虚拟内存利用DRAM缓存来自通常更大的虚拟地址空间的页面。
  2. 虚拟内存作为内存管理的工具。操作系统为每个进程提供了一个独立的页表,也就是独立的虚拟地址空间。多个虚拟页面可以映射到同一个物理页面上。
    • 简化链接: 独立的地址空间允许每个进程的内存映像使用相同的基本格式,而不管代码和数据实际存放在物理内存的何处。例如:一个给定的linux系统上的每个进程都是用类似的内存格式,对于64为地址空间,代码段总是从虚拟地址)0x400000开始,数据段,代码段,栈,堆等等。
    • 简化加载: 虚拟内存还使得容易向内存中加载可执行文件和共享对象文件。要把目标文件中.text和.data节加载到一个新创建的进程中,Linux加载器为代码和数据段分配虚拟页VP,把他们标记为无效(未被缓存) ,将页表条目指向目标文件的起始位置。加载器从不在磁盘到内存实际复制任何数据,在每个页初次被引用时,虚拟内存系统会按照需要自动的调入数据页。
    • 简化共享: 独立地址空间为OS提供了一个管理用户进程和操作系统自身之间共享的一致机制。
      一般:每个进程有各自私有的代码,数据,堆栈,是不和其他进程共享的,这样OS创建页表,将虚拟页映射到不连续的物理页面。某些情况下,需要进程来共享代码和数据。例如每个进程调用相同的操作系统内核代码,或者C标准库函数。OS会把不同进程中适当的虚拟页面映射到相同的物理页面。
    • 简化内存分配: 虚拟内存向用户提供一个简单的分配额外内存的机制。当一个运行在用户进程中的程序要求额外的堆空间时(如malloc),OS分配一个适当k大小个连续的虚拟内存页面,并且将他们映射到物理内存中任意位置的k个任意物理页面,因此操作系统没有必要分配k个连续的物理内存页面,页面可以随机的分散在物理内存中。
  3. 虚拟内存作为内存保护的工具。不应该允许一个用户进程修改它的只读段,也不允许它修改任何内核代码和数据结构,不允许读写其他进程的私有内存,不允许修改任何与其他进程共享的虚拟页面。每次CPU生成一个地址时,MMU会读一个PTE,通过在PTE上添加一些额外的许可位来控制对一个虚拟页面内容的访问十分简单。

什么是信号
一个信号就是一条小消息,它通知进程系统中发生了一个某种类型的事件。 Linux 系统上支持的30 种不同类型的信号。 每种信号类型都对应于某种系统事件。低层的硬件异常是由内核异常处理程序处理的,正常情况下,对用户进程而言是不可见的。信号提供了一种机制,通知用户进程发生了这些异常。
发送信号:内核通过更新目的进程上下文中的某个状态,发送(递送)一个信号给目的进程。发送信号可以有如下两种原因:
内核检测到一个系统事件,比如除零错误或者子进程终止。
—个进程调用了kill 函数, 显式地要求内核发送一个信号给目的进程。一个进程可以发送信号给它自己。
接收信号:当目的进程被内核强迫以某种方式对信号的发送做出反应时,它就接收了信号。进程可以忽略这个信号,终止或者通过执行一个称为信号处理程序(signal handler)的用户层函数捕获这个信号。

什么是线程?

  1. 是进程划分的任务,是一个进程内可调度的实体,是CPU调度的基本单位,用于保证程序的实时性,实现进程内部的并发。
  2. 线程是操作系统可识别的最小执行和调度单位。每个线程都独自占用一个虚拟处理器:独自的寄存器组,指令计数器和处理器状态。
  3. 每个线程完成不同的任务,但是属于同一个进程的不同线程之间共享同一地址空间(也就是同样的动态内存,映射文件,目标代码等等),打开的文件队列和其他内核资源。

进程的调度策略
先到先服务调度算法、短作业优先调度算法、优先级调度算法、时间片轮转调度算法、高响应比优先调度算法、多级队列调度算法、多级反馈队列调度算法

请说一下什么是写时复制?
如果有多个进程要读取它们自己的那部门资源的副本,那么复制是不必要的。每个进程只要保存一个指向这个资源的指针就可以了。只要没有进程要去修改自己的“副本”,就存在着这样的幻觉:每个进程好像独占那个资源。从而就避免了复制带来的负担。如果一个进程要修改自己的那份资源“副本”,那么就会复制那份资源,并把复制的那份提供给进程。不过其中的复制对进程来说是透明的。这个进程就可以修改复制后的资源了,同时其他的进程仍然共享那份没有修改过的资源。所以这就是名称的由来:在写入时进行复制。
写时复制的主要好处在于:如果进程从来就不需要修改资源,则不需要进行复制。惰性算法的好处就在于它们尽量推迟代价高昂的操作,直到必要的时刻才会去执行。
在使用虚拟内存的情况下,写时复制(Copy-On-Write)是以页为基础进行的。所以,只要进程不修改它全部的地址空间,那么就不必复制整个地址空间。在fork()调用结束后,父进程和子进程都相信它们有一个自己的地址空间,但实际上它们共享父进程的原始页,接下来这些页又可以被其他的父进程或子进程共享。

进程的基本操作
以Unix系统举例:

  1. 进程的创建:fork()。新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用 fork 时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的 PID。fork函数是有趣的(也常常令人迷惑), 因为它只被调用一次,却会返回两次:一次是在调用进程(父进程)中,一次是在新创建的子进程中。在父进程中,fork 返回子进程的 PID。在子进程中,fork 返回 0。因为子进程的 PID 总是为非零,返回值就提供一个明 确的方法来分辨程序是在父进程还是在子进程中执行。
    pid_t fork(void);
  2. 回收子进程:当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除。相反,进程被保持在一种已终止的状态中,直到被它的父进程回收(reaped)。当父进程回收已终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃已终止的进程。一个进程可以通过调用 waitpid 函数来等待它的子进程终止或者停止。
    pid_t waitpid(pid_t pid, int *statusp, int options);
  3. 加载并运行程序:execve 函数在当前进程的上下文中加载并运行一个新程序。
    int execve(const char filename, const char argv[], const char *envp[]);
  4. 进程终止:
    void exit(int status);

不能进行进程调度的情况

  1. 在中断处理程序执行时。
  2. 在操作系统的内核程序临界区内。
  3. 其它需要完全屏蔽中断的原子操作过程中。

进程同步的方法
操作系统中,进程是具有不同的地址空间的,两个进程是不能感知到对方的存在的。有时候,需要多个进程来协同完成一些任务。 当多个进程需要对同一个内核资源进行操作时,这些进程便是竞争的关系,操作系统必须协调各个进程对资源的占用,进程的互斥是解决进程间竞争关系的方法。 进程互斥指若干个进程要使用同一共享资源时,任何时刻最多允许一个进程去使用,其他要使用该资源的进程必须等待,直到占有资源的进程释放该资源。 当多个进程协同完成一些任务时,不同进程的执行进度不一致,这便产生了进程的同步问题。需要操作系统干预,在特定的同步点对所有进程进行同步,这种协作进程之间相互等待对方消息或信号的协调关系称为进程同步。进程互斥本质上也是一种进程同步。 进程的同步方法:
互斥锁、读写锁、条件变量、记录锁(record locking)、信号量、屏障(barrier)

简述进程间通信方法

  1. 每个进程各自有不同的用户地址空间,任何一个进程的全局变量在另一个进程中都看不到,所以进程之间要交换数据必须通过内核,在内核中开辟一块缓冲区,进程A把数据从用户空间拷到内核缓冲区,进程B再从内核缓冲区把数据读走,内核提供的这种机制称为进程间通信。
  2. 不同进程间的通信本质:进程之间可以看到一份公共资源;而提供这份资源的形式或者提供者不同,造成了通信方式不同。
  3. 进程间通信主要包括管道、系统IPC(包括消息队列、信号量、信号、共享内存等)、以及套接字socket。

进程如何通过共享内存通信?
它使得多个进程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存中数据得更新。这种方式需要依靠某种同步操作,如互斥锁和信号量等。
特点:

  1. 共享内存是最快的一种IPC,因为进程是直接对内存进行操作来实现通信,避免了数据在用户空间和内核空间来回拷贝。
  2. 因为多个进程可以同时操作,所以需要进行同步处理。
  3. 信号量和共享内存通常结合在一起使用,信号量用来同步对共享内存的访问。

进程如何通过管道进行通信
管道是一种最基本的IPC机制,作用于有血缘关系的进程之间,完成数据传递。调用pipe系统函数即可创建一个管道。有如下特质:

  1. 其本质是一个伪文件(实为内核缓冲区)
  2. 由两个文件描述符引用,一个表示读端,一个表示写端。
  3. 规定数据从管道的写端流入管道,从读端流出。
    管道的原理: 管道实为内核使用环形队列机制,借助内核缓冲区实现。
    管道的局限性:
  4. 数据自己读不能自己写。
  5. 数据一旦被读走,便不在管道中存在,不可反复读取。
  6. 由于管道采用半双工通信方式。因此,数据只能在一个方向上流动。
  7. 只能在有公共祖先的进程间使用管道。

为什么捕获函数在子进程里面依然有效?
因为子进程复制了父进程的代码和数据,子进程自然也会包含信号处理函数的代码,所在子进程中依然有效。

fork之前,父进程设置的处理方式是忽略或默认时,子进程exec加载新程序后,忽略和默认设置依然有效。
如果子进程所继承的信号处理方式是捕获的话,exec加载新程序后,捕获处理方式会被还原为默认处理方式。

  • exec新程序的代码会覆盖子进程中原有的父进程的代码,信号捕获函数的代码也会被覆盖
  • 既然捕获函数已经不存在了,捕获处理方式自然也就没有意义了,所以信号的处理方式会被还原为默认处理方式。

与 fork 或 vfork 函数不同,exec 函数不是创建调用进程的子进程,而是创建一个新的进程取代调用进程自身。新进程会用自己的全部地址空间,覆盖调用进程的地址空间,但进程的 PID 保持不变。exec 只是用磁盘上的一个新程序替换了当前进程的正文段、数据段、堆段和栈段。

exec只是用磁盘上的一个新程序替换了当前进程的正文段, 数据段, 堆段和栈段.并没有创建新进程,所以进程的ID是不变的。但实际测试发现:exec执行后,执行的程序的pid可能就是子进程的,也可能会变,跟系统有关。
execl,execlp,execle(都带“l”, 代表list)的参数个数是可变的,参数以必须一个空指针结束。
execv和execvp的第二个参数是一个字符串数组(“v”代表“vector”,字符串数组必须以NULL结尾),新程序在启动时会把在argv数组中给定的参数传递到main。
名字最后一个字母是“p”的函数会搜索PATH环境变量去查找新程序的可执行文件。如果可执行文件不在PATH定义的路径上,就必须把包括子目录在内的绝对文件名做为一个参数传递给这些函数;
总结:l代表可变参数列表,与v互斥;v表示该函数取一个argv[]矢量;p代表在path环境变量中搜索file文件;e表示该函数取envp[]数组,而不使用当前环境

以下为system函数的超时版本实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
struct ProcessInfo
{
int pid;
std::chrono::steady_clock::time_point expiryTime;
};

std::map<int, std::chrono::steady_clock::time_point> cmdProcesses;
std::mutex processesMutex;

void timerThread()
{
while (true)
{
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << "timerThread running..." << std::endl;
// 使用互斥锁保护 cmdProcesses 向量
auto currentTime = std::chrono::steady_clock::now();
std::lock_guard<std::mutex> lock(processesMutex);
for (auto it = cmdProcesses.begin(); it != cmdProcesses.end();)
{
if (it->second <= currentTime)
{
// Time expired, kill the process and remove it from the map
std::cout << "Killing process with PID: " << it->first << std::endl;
timeoutHandler(it->first);
it = cmdProcesses.erase(it);
}
else
{
++it;
}
}
}
}

int executeCommandWithTimeout(const char *command, int timeoutInSeconds)
{
// 创建子进程
pid_t pid = fork();

if (pid == -1)
{
perror("fork");
exit(EXIT_FAILURE);
}
else if (pid == 0)
{
// 子进程执行的代码
printf("child program pid: %d\n", getpid());
// 执行系统命令
execl("/bin/sh", "sh", "-c", command, NULL);
// 如果 execl 执行失败
perror("execl");
exit(EXIT_FAILURE);
}
else
{
{
std::lock_guard<std::mutex> lock(processesMutex);
cmdProcesses[pid] = std::chrono::steady_clock::now() + std::chrono::seconds(timeoutInSeconds);
}
printf("wait child to exit...\n");
// 等待子进程退出
int status;
waitpid(pid, &status, 0);

{
std::lock_guard<std::mutex> lock(processesMutex);
auto it = cmdProcesses.find(pid);
if (it != cmdProcesses.end())
{
cmdProcesses.erase(it);
std::cout << "Removed process with PID " << pid << std::endl;
}
}

if (WIFEXITED(status))
{
printf("Command exited with status %d.\n", WEXITSTATUS(status));
return WEXITSTATUS(status);
}
else
{
printf("Command did not exit normally.\n");
return EXIT_FAILURE;
}
}
}

popen版本超时实现,利用pgrep找到当前进程下的子进程,如果超时没有关闭,发送term信号。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
std::string executeCommandPipe(const std::string &command, int timeoutInSeconds)
{
int pipefd[2];
if (pipe(pipefd) == -1)
{
std::cerr << "Error: Unable to create pipe." << std::endl;
return "";
}

pid_t pid = fork();

if (pid == -1)
{
std::cerr << "Error: Unable to fork." << std::endl;
return "";
}

if (pid == 0)
{ // Child process
printf("child program pid: %d\n", getpid());
close(pipefd[0]); // Close unused read end

// Redirect standard output to the write end of the pipe
dup2(pipefd[1], STDOUT_FILENO);

// Execute the command
execl("/bin/sh", "sh", "-c", command.c_str(), nullptr);

// If execl fails
std::cerr << "Error: Unable to execute the command." << std::endl;
_exit(EXIT_FAILURE);
}
else
{ // Parent process
{
std::lock_guard<std::mutex> lock(processesMutex);
cmdProcesses[pid] = std::chrono::steady_clock::now() + std::chrono::seconds(timeoutInSeconds);
}
close(pipefd[1]); // Close unused write end

// Read from the read end of the pipe
char buffer[128];
std::string result;

ssize_t bytesRead;
while ((bytesRead = read(pipefd[0], buffer, sizeof(buffer))) > 0)
{
result.append(buffer, bytesRead);
}

close(pipefd[0]); // Close read end

printf("wait child to exit...\n");
int status;
waitpid(pid, &status, 0);

{
std::lock_guard<std::mutex> lock(processesMutex);
auto it = cmdProcesses.find(pid);
if (it != cmdProcesses.end())
{
cmdProcesses.erase(it);
std::cout << "Removed process with PID " << pid << std::endl;
}
}

if (WIFEXITED(status))
{
printf("Command exited with status %d.\n", WEXITSTATUS(status));
}
else
{
printf("Command did not exit normally.\n");
}

return result;
}
}

nephen wechat
欢迎您扫一扫上面的微信公众号,订阅我的博客!
坚持原创技术分享,您的支持将鼓励我继续创作!