关于后端:Linux-系统编程从入门到进阶-学习指南

7次阅读

共计 30077 个字符,预计需要花费 76 分钟才能阅读完成。

引言

大家好,我是小康,明天咱们来学习一下 Linux 零碎编程相干的常识。Linux 零碎编程是连贯高级语言和硬件的桥梁,它对深刻了解计算机系统至关重要。无论你是打算构建高性能服务器还是开发嵌入式设施,把握 Linux 零碎编程是 C 和 C++ 开发者的基本技能。

本文旨在为初学者提供一个清晰的 Linux 零碎编程入门指南,带你步入 Linux 零碎编程的世界,从基本概念到实用技能,一步步建设起您的常识体系。

基本概念

什么是零碎编程?

零碎编程,指的是开发那些间接与计算机硬件或操作系统进行交互的程序。这些程序负责管理和管制计算机系统的资源,包含但不限于过程、内存、文件系统和设施驱动。确保为应用程序提供一个稳固、高效的运行环境。

零碎编程与利用编程的次要区别

  • 目的性:零碎编程旨在为计算机或操作系统自身提供性能和服务,而利用编程是为了满足最终用户的特定需要。
  • 交互对象:零碎编程间接与硬件或操作系统交互,而利用编程与操作系统或其余利用交互。
  • 复杂性:因为零碎编程须要治理和管制计算机的底层资源,因而通常比利用编程更为简单。
  • 开发工具:零碎编程通常应用低级语言,如 C 或汇编,因为这些语言提供了间接拜访硬件的能力。而利用编程可能应用更高级的语言,如 Python 或 Java,以进步开发效率。

Linux 零碎编程核心技术概览

在电脑的世界中,操作系统起到桥梁的作用,连贯用户与计算机硬件。其中,Linux 因为其开源、稳固和平安的特点,成为了许多工程师的首选。为了更深刻地了解它,咱们首先须要理解其零碎架构的神秘面纱。

Linux 零碎架构解析

用户空间和内核空间的布局

各个内核组件阐明:

  • 零碎调用(Syscalls)

    当应用程序须要拜访硬件资源时,它们应用零碎调用来与内核通信。

  • 过程治理

    负责解决过程创立、调度和终止。确保零碎中的过程偏心、无效地取得 CPU 工夫,并治理过程间的通信和同步。

  • 内存治理

    治理物理内存,提供虚拟内存和分页性能。确保每个过程都有它本人的地址空间,同时爱护过程间的内存不被非法拜访。

  • 文件系统

    提供文件和目录的创立、读取、写入和删除性能。它形象了物理存储设备,为用户和应用程序提供了一个对立的文件拜访接口。

  • 虚构文件系统(VFS)

    用户和应用程序不间接与各种文件系统交互。而是通过 VFS(虚构文件系统)进行操作。VFS 为各种不同的文件系统(如 EXT4, FAT, NFS 等)提供一个对立的接口。这样,无论底层应用的是哪种文件系统,用户和利用的文件拜访形式都保持一致,实现在 Linux 中的无缝集成。

  • 网络协议栈

    负责解决计算机之间的通信,使设施可能在网络上发送和接收数据。它蕴含了多层协定,如 TCP/IP,使计算机可能连贯到互联网和其余网络,并与其余计算机进行数据交换。

  • 设施驱动

    设施驱动是一种非凡的软件程序,它容许 Linux 内核和计算机的硬件组件进行交互。这些硬件组件能够是任何物理设施,如显卡、声卡、网络适配器、硬盘或其余输出 / 输出设备。设施驱动为硬件设施提供了一个形象层,使得内核和应用程序不须要晓得硬件的具体细节,就能与其进行通信和管制。简而言之,设施驱动是硬件和操作系统之间通信的桥梁。

用户空间 (User Space)

所有的应用程序,如浏览器、文档编辑器或音乐播放器都运行在这个空间。

  • 安全性:用户空间的程序运行在受限的环境中,它们只能拜访调配给它们的资源,不能间接拜访硬件或其余程序的数据。
  • 稳定性:如果一个应用程序解体,它不会影响其余应用程序或零碎的外围性能。

内核空间 (Kernel Space)

内核空间是操作系统的外围。

  • 权限:内核能够间接拜访硬件,并有权执行任何命令。
  • 安全性:尽管内核领有宽泛的权限,但只有那些已知且通过严格测试和验证的代码才被容许在内核空间执行。
  • 稳定性:如果内核遇到问题,整个零碎可能会解体。

零碎调用与库函数

Linux 编程中,咱们常常听到“零碎调用”和“库函数”这两个词,但你晓得它们之间的区别吗?接下来就让咱们来具体理解一下。

什么是零碎调用?

零碎调用是一个程序向操作系统收回的申请。当应用程序须要拜访某些资源(如磁盘、网络或其余硬件设施)或执行某些特定的操作(如创立过程或线程)时,它通常会通过零碎调用来实现。

工作原理

  • 模式切换:应用程序在用户空间运行,而操作系统内核在内核空间运行。零碎调用波及从用户空间切换到内核空间。
  • 参数传递:程序将参数传递给零碎调用,通常通过特定的寄存器。
  • 执行:内核依据传递的参数执行相应的操作。
  • 返回后果:操作实现后,内核将后果返回给应用程序,并将控制权返回给应用程序。

常见的零碎调用函数:

read() 和 write():别离用于读取和写入文件。open() 和 close():关上和敞开文件。fork():创立一个新的过程。wait():期待过程完结。exec():执行一个新程序。

这只是零碎调用的冰山一角。Linux 提供了上百个零碎调用,每个都有其特定的性能。

什么是库函数?

库函数是预编写的代码,存储在库文件中,供程序员应用。它们通过零碎调用和操作系统的内核通信。例如,printf()是 C 语言的一个库函数,它外部应用 write()零碎调用来和内核进行交互。

文件 IO

文件 IO(输出 / 输入)是计算机程序与文件系统交互的根本形式,容许程序读取和写入文件。要深刻了解和应用文件 IO,首先须要理解一些要害概念和操作。

文件描述符是什么?

文件描述符「fd」是一个整数,它代表了一个关上的文件。在 Linux 中,每次咱们关上或创立一个文件时,零碎都会返回一个文件描述符。而应用程序正是通过这个文件描述符「fd」来进行文件的读写的。

非凡的文件描述符:

  • 规范输出「stdin」 是 0
  • 规范输入「stdout」 是 1
  • 规范谬误 「stderr」 是 2

常见的文件操作

当应用程序要与文件交互时,最根本的操作包含关上、读取、写入和敞开文件。这能够通过以下函数来实现。

关上文件:open()
读取文件:read()
写入文件:write()
敞开文件:close()

# demo
int fd = open("example.txt", O_RDWR | O_CREAT);
write(fd, "Hello, File!", 12);
close(fd);

文件地位与挪动

有时,咱们可能须要挪动到文件的特定地位进行读写。应用 lseek()能够实现这一点。举个例子:

/* 
假如咱们有一个名为 "data.txt" 的文件,内容为:Hello World!
 当初咱们有一个简略需要:咱们想将文件中的 "World" 替换为 "Linux",但不想重写整个文件。*/

# demo 展现:char buffer[6];  // 寄存从文件中读取的数据

int fd = open("data.txt", O_RDWR);  # 以读写模式关上文件
lseek(fd, 6, SEEK_SET);  // 应用 lseek() 挪动到 "World" 的结尾地位
read(fd, buffer, 5);     // 读取 5 个字符("World" 的长度)if (strcmp(buffer, "World") == 0) {
    // 从新定位文件指针以替换 "World", 这里须要从新定位的起因是:下面 read 操作使得文件指针曾经指向文件开端了,因而须要从新定位。lseek(fd, 6, SEEK_SET);
    write(fd, "Linux", 5);
}
close(fd) ; 

高级文件 I/O

有时,简略的读写操作无奈满足咱们的需要,尤其当咱们谋求高效率或非凡性能时。为了更优雅、高效地解决文件数据,咱们引入了一些高级文件 I/O 技巧。

扩散读取和集中写入

#include <sys/uio.h>
// 读取操作
ssize_t readv(int fd, const struct iovec *iov, int iovcnt);
// 写入操作
ssize_t writev(int fd, const struct iovec *iov, int iovcnt);

# iovec 构造的定义如下:struct iovec {
    void  *iov_base;  
    size_t iov_len; 
};

iov_base 是指向缓冲区起始地址的指针。iov_len 是缓冲区的大小。

这两个函数次要用于多缓冲区的输出 / 输入操作,容许您在单次零碎调用中,从文件读取到多个缓冲区或从多个缓冲区写入文件。

它们的次要目标是提高效率,因为惯例的读 / 写函数每次只能在一个缓冲区进行操作。

内存映射文件 I /O

内存映射文件 I/O 容许程序员将文件的一部分间接映射到过程的内存中。这样,程序能够通过间接拜访这块内存来拜访文件的内容,而不是应用传统的 read、write 零碎调用。这能够提高效率,特地是对于大文件的拜访。

#include <sys/mman.h>

// 相干函数申明
void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void* addr, size_t length);

// demo 举例:

int fd = open("example.txt", O_RDWR);
// 获取文件的大小
struct stat sb;
if (fstat(fd, &sb) == -1) {perror("fstat");
}
char *mapped = mmap(NULL, sb.st_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);

// 后续的所有对文件的操作就能够通过 mapped 指针来进行。// 例如:将第一个字符改为 'J')mapped[0] = 'J';
...

应用 mmap,你能够间接在内存中拜访文件内容,如同拜访数组或其余数据结构一样。

同步文件操作

当您向文件写入数据时,操作系统可能会缓存这些数据,而不是立刻写入磁盘, 这样能够提高效率。但在某些状况下,您可能须要确保数据的确曾经写入磁盘。这就是同步文件操作的用途。

#include <unistd.h>
#include <sys/mman.h>

int msync(void *addr, size_t length, int flags);
int fsync(int fd);
int fdatasync(int fd);
void sync(void);
  • msync 用于同步内存映射(通过 mmap 函数创立)文件的内容。它将内存中的更改写回到映射的文件中。
  • fsync 函数用于将指定文件描述符(fd)关联的文件的所有批改(包含数据和元数据)同步到磁盘
  • fdatasync 函数相似于 fsync,但它只同步文件的数据局部,而不同步元数据。
  • sync 同步整个文件系统的所有批改的数据到磁盘,包含所有关上的文件。

文件锁定

什么是文件锁定?

文件锁定是一个在多个过程或线程之间协调对共享资源拜访的机制。在这里,这个 ” 共享资源 ” 指的是文件。简略说,文件锁 就是确保当一个过程正在应用一个文件时,其余过程不能批改它。

为什么须要文件锁定?

思考这样一个场景:两个程序同时写入一个文件。不锁定文件可能会导致数据凌乱。例如,一个过程可能会笼罩另一个过程的更改。所以,文件锁定是确保数据完整性的要害。

文件锁的两种模式

  • 共享锁(Shared Locks):也被称为读锁。当一个过程持有共享锁时,其余过程能够取得该文件的共享锁以进行读取,但不能取得独占锁进行写入。
  • 独占锁(Exclusive Locks):也被称为写锁。当一个过程持有独占锁时,其余过程不能取得该文件的任何类型的锁。这意味着其余过程不能够读取或写入该文件。

如何实现文件锁定?

在 Linux 编程中,文件锁定能够应用以下函数实现:

fcntl() : 容许对文件中的特定局部进行锁定。

flock():提供了一个简化的锁定机制,间接锁定整个文件。

重定向

什么是重定向?

重定向,顾名思义,指的是扭转数据流的方向。在 Linux 零碎编程中,程序通常与三种规范 I /O 流进行交互:规范输出(stdin)、规范输入(stdout)、和规范谬误输入(stderr)。

  • 规范输出(stdin): 来自键盘的输出。
  • 规范输入(stdout): 显示到屏幕上。
  • 规范谬误输入(stderr) : 也显示到屏幕上。

重定向的外围是将这些规范的 I/O 流扭转到其余中央,如文件或其余程序。

例如,当咱们在命令行中执行命令并将后果保留到文件中,或者从文件中获取命令的输出而不是从键盘中获取,咱们都是在应用重定向。

# 将 ls -l 命令的输入(即当前目录的具体列表)重定向到 filelist.txt 文件中
ls -l > filelist.txt   

重定向不仅局限于命令行界面,它在程序中也很有用,容许咱们动静地更改程序的输出和输入起源,为构建更简单、灵便的应用程序提供了根底。

Linux 零碎编程中,实现重定向的一个外围函数是 dup2 函数。

#include <unistd.h>

int dup2(int oldfd, int newfd);
/*
其中:oldfd 是原始文件描述符。newfd 是要复制到的指标文件描述符。*/

# demo 举例:

int main() {
    // 关上一个文件用于写入
    int file_fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (file_fd < 0) {
      // 错误处理
      ...
    }

    // 应用 dup2 将规范输入重定向到文件
    if (dup2(file_fd, STDOUT_FILENO) < 0) {
     // 错误处理
      ...
    }

    // 当初,所有规范输入都会被写入文件
    printf("This will be written to the file'output.txt'\n");
    close(file_fd);
    return 0;
}

Linux 过程

你有没有想过,当你在 Linux 操作系统上运行一个程序时,都产生了哪些神奇的事件?接下来,咱们将一步一步地深入探讨 Linux 过程的世界。

过程到底是什么?

每当你启动一个程序,Linux 零碎都会创立一个新的过程。这个过程有它本人的内存地址、系统资源和状态。简而言之,过程是程序的一个运行实例。

过程的创立和终止

fork():当调用 fork 函数时,它会创立一个新的子过程。这个子过程简直是父过程的复制品,包含父过程的内存、程序计数器等。

wait() & waitpid():这些函数容许父过程期待子过程的完结,并收集子过程的退出状态。防止出现僵尸过程。

exec() 系列函数exec 系列函数 容许一个过程运行另一个程序,它实际上替换了以后过程的内容。

过程的状态转换图

五态简要阐明:

  • 新建状态: 这是过程刚被创立时的状态。在这个状态下,操作系统为过程调配了一个惟一的过程标识符(PID)和必要的资源。但过程还没有开始执行任何代码。新建状态通常十分短暂,用户很难察看到,因为过程很快就会转移到 「就绪状态」
  • 就绪状态 : 过程已筹备好运行并期待操作系统的调度器调配 CPU 工夫片。在这个状态下,过程曾经加载了所有必要的代码和数据到内存中,且已筹备好执行。
  • 运行状态 : 过程正在 CPU 上执行。一个过程只有在运行状态时能力执行其指令。
  • 阻塞状态 : 过程不能执行,因为它在期待一些事件产生,例如 I/O 操作的实现、信号的接管等。在此状态下,即便 CPU 闲暇,过程也不能执行。
  • 终止状态 : 过程已实现执行或被终止。在这个状态下,过程的资源通常被回收,过程退出。

过程间通信

在 Linux 的世界里,过程是操作系统进行资源分配的根本单位。然而,过程并不是孤立的存在。当你的利用分成多个独立运行的过程时,这些过程之间如何无效地替换信息呢?这正是通过过程间通信的形式来实现的。

Linux 提供了以下几种过程间通信的形式

1. 管道(Pipe)

管道是 Linux 中用于过程间通信的一种机制。它们分为两种类型:匿名管道 有名管道

匿名管道 :

概念:匿名管道是一种在有亲缘关系的过程间(如父子过程)进行单向数据传输的通信机制,存在于内存中,通常用于长期通信。如果须要双向通信,则个别须要两个管道。

简略图解:

应用场景:实用于有亲缘关系的过程间的简略数据传输。

简略示例:

  #include <unistd.h>
  int main() {int pipefd[2];
    pipe(pipefd); // 创立匿名管道
    if (fork() == 0) { // 子过程
        close(pipefd[1]); // 敞开写端
        // 读取数据
        read(pipefd[0],buf,5);
        // ... 
    } else { // 父过程
        close(pipefd[0]); // 敞开读端
        // 写入数据
        write(pipefd[1],"hello",5);
        // ... 
    }
  }

有名管道

概念: 有名管道(FIFO,First-In-First-Out) 是一种非凡类型的文件,用于在不相干的过程之间实现通信。与匿名管道不同,有名管道在文件系统中具备一个理论的路径名。这容许任何具备适当权限的过程关上和应用它,而不仅限于有亲缘关系的过程。

简略图解:

简略阐明

有名管道是 Linux 中一种非凡的文件,它容许不同的过程通过读写这个文件来互相通信。

应用场景:用于本机任何两个过程间的通信,特地是当这些过程没有血缘关系时。

简略示例:

// server.c
int main() {
    const char *fifoPath = "/tmp/my_fifo";
    mkfifo(fifoPath, 0666); // 创立有名管道
    char buf[1024];
    int fd;
    // 永恒循环,继续监听有名管道
    while (1) {fd = open(fifoPath, O_RDONLY); // 关上管道进行读取
        read(fd, buf, sizeof(buf));

        // 打印接管到的音讯
        printf("Received: %s\n", buf);
        close(fd);
    }
    return 0;
}

// client.c
int main() {
    const char *fifoPath = "/tmp/my_fifo";
    char buf[1024];
    int fd;
    printf("Enter message:");    // 获取要发送的音讯
    fgets(buf, sizeof(buf), stdin);
    fd = open(fifoPath, O_WRONLY); // 关上管道进行写入
    write(fd, buf, strlen(buf) + 1);
    close(fd);
    return 0
}

2. 信号 (Signals)

概念
在 Linux 中,信号是一种用于过程间通信(IPC)的机制,容许操作系统或一个过程向另一个过程发送简略的音讯。信号次要用于传递对于零碎事件的告诉,例如中断请求、程序异样、或其余重要事件。每个信号代表了一个特定类型的事件,并且过程能够依据收到的信号执行相应的动作。

信号是异步的,意味着它们能够在任何工夫点被发送到过程,通常与过程的失常控制流无关。信号的应用为过程提供了一种解决内部事件和谬误的形式。

能够应用命令 kill -l 来查看 Linux 零碎反对的信号有哪些?

~$ kill -l
 1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP
 6) SIGABRT      7) SIGBUS       8) SIGFPE       9) SIGKILL     10) SIGUSR1
11) SIGSEGV     12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM
...

应用场景

  • 异样解决:当程序遇到运行时谬误,比方除以零、非法内存拜访等,操作系统会向该过程发送一个适当的信号,如 SIGFPE(浮点异样)、SIGSEGV(段谬误)。默认状况下:都会使程序终止。
  • 内部中断:用户能够通过特定的键盘输入(最常见的是 Ctrl+C)来中断正在终端上运行的过程。这会生成 SIGINT 信号,通常导致程序终止。
  • 过程管制:如应用 kill 命令发送信号来终止或暂停某个过程。
  • 定时器和超时:程序能够设置定时器,当定时器到期时,会收到 SIGALRM 信号。这罕用于限度某些操作的执行工夫,确保它们不会占用过多工夫。
  • 子过程状态变动:当一个子过程完结或进行时,它的父过程会收到 SIGCHLD 信号。这使得父过程能够监控其子过程的状态变动(从运行到失常退出)。

简略示例:

void signal_handler(int signal_num) {printf("Received signal: %d\n", signal_num);
}

int main() {signal(SIGINT, signal_handler);  // 注册信号处理函数
    // 有限循环,期待信号
    while (1) {sleep(1); // 暂停一秒
    }
    return 0;
}

在这个例子中,程序设置了一个信号处理函数来解决 SIGINT 信号(通常由 Ctrl+C 产生)。当收到该信号时,signal_handler 函数会被调用。

以下是对上述代码执行流程的简略图解阐明,不便大家了解

3. 文件(Files)

概念

文件在 Linux 零碎中是一种根本的长久化存储机制,可用于 过程间通信。多个过程能够通过对同一个文件的读取和写入来共享信息。

简略图解:

应用场景:

  • 数据交换:

    过程之间能够通过读写同一文件来替换数据。例如,一个过程写入后果数据,另一个过程读取这些数据进行进一步解决。

  • 长久化存储:

    文件用于保留须要在应用程序重启后仍然保留的数据,例如用户数据、利用状态等。

简略示例:

// 写过程: 向文件中写入数据
int main() {
    const char *file = "/tmp/ipc_file";
    int fd = open(file, O_RDWR | O_CREAT, 0666);
    write(fd, "Hello from Process A", 20);  // 向文件写入数据
    close(fd);     // 敞开文件
    return 0;
}
// 读过程: 从文件中读取数据

int main() {
    const char *file = "/tmp/ipc_file";
    int fd = open(file, O_RDWR | O_CREAT, 0666);
    char buf[50];
    read(fd, buf, 20);  // 从文件中读取数据
    close(fd);     // 敞开文件
    return 0;
}

留神:
如果存在多个写过程同时操作同一个文件,那么会引发数据竞态和一致性问题。为了解决这个问题,能够应用文件锁或其余同步机制来协调对文件的拜访,确保数据的完整性和一致性。

文件锁的作用:

  • 避免数据笼罩
    当一个过程正在写文件时,文件锁能够避免其余过程同时写入,从而防止数据被笼罩。
  • 保障写操作的完整性

    通过锁定文件,确保每次只有一个过程可能执行写操作,这有助于放弃写入数据的完整性。

实现文件锁:

在 Linux 中,能够应用 fcntl 或 flock 零碎调用来实现文件锁。

示例代码

应用 fcntl 实现文件锁,从而保障多个过程在操作同一文件时不会互相烦扰,保护数据的一致性和完整性。以下是一个具体的示例:

int main() {
    const char *file = "/tmp/ipc_file";
    int fd = open(file, O_RDWR | O_CREAT, 0666);
    // 设置文件锁
    struct flock fl;
    fl.l_type = F_WRLCK;  // 设置写锁
    fl.l_whence = SEEK_SET;
    fl.l_start = 0;
    fl.l_len = 0;  // 锁定整个文件
    if (fcntl(fd, F_SETLKW, &fl) == -1) {perror("Error locking file");
        return -1;
    }
    write(fd, "Hello from Process A", 20); // 执行写操作
    // 开释锁
    fl.l_type = F_UNLCK;
    fcntl(fd, F_SETLK, &fl);
    close(fd);
    return 0;
}

4. 信号量(Semaphores)

概念 :
信号量是一种在过程间或同一过程的不同线程间提供同步的机制。它是一个计数器,用于管制对共享资源的拜访。当计数器值大于 0 时,示意资源可用;当值为 0 时,示意资源被占用。过程在访问共享资源前必须缩小(wait)信号量,拜访后必须减少(post)信号量。

信号量有两种,一种是 POSIX 信号量,另一种是 System V 信号量。因为 POSIX 信号量提供了更简洁、更易于了解和应用的 API,并且在古代操作系统中失去了广泛支持和优化,所以这里我重点解说 POSIX 信号量。

简略图解:

分类:

匿名信号量

概念:

匿名信号量是内存中的信号量,不与任何文件系统的名称关联。它们通常用于繁多过程内不同线程间的同步,或在具备独特先人的过程之间进行同步。

特点:

  • 作用域:限于创立它的过程外部或其子过程之间。
  • 生命周期:与创立它们的过程的生命周期雷同,过程终止时信号量也会隐没。

应用场景

  • 互斥拜访:在多线程程序中,确保同一时刻只有一个线程能够拜访某个共享资源。
  • 同步操作:协调多个线程的执行程序,一个线程在另一个线程实现其工作之后再开始执行。如:线程池中的工作队列没工作时,线程必须期待,而当有有线程向队列增加工作时,须要唤醒其余线程来进行生产工作。

    有名信号量

    概念: 有名信号量在文件系统中具备一个惟一的名称,容许不同的独立过程通过这个名称拜访同一个信号量,实现过程间同步。

    特点:

    • 作用域:能够跨不同的过程应用。它们在文件系统中具备一个全局惟一的名称,任何晓得这个名称的过程都能够拜访同一个信号量。
    • 生命周期:生命周期能够超过创立它们的过程。即便创立它们的过程曾经完结,只有有名信号量的名称存在于文件系统中,它们就持续存在。

应用场景

  • 过程间互斥: 多个独立过程共享资源,如文件或内存映射区域,须要互斥拜访以防止抵触。
  • 同步操作:协调多个过程的执行程序,一个过程在另一个过程实现其工作之后再开始执行。如:在生产者消费者模型中,只有当生产者向队列增加数据,队列不为空的时候,消费者能力生产数据,否则只能期待。

来看一个过程互斥的例子:

// 假如日志文件曾经关上
FILE* logFile;

void writeToLog(const char* message) {sem_t* sem = sem_open("/log_semaphore", O_CREAT, 0644, 1);

    sem_wait(sem);  // 获取信号量
    fprintf(logFile, "%s\n", message);  // 写入日志
    fflush(logFile);
    sem_post(sem);  // 开释信号量

    sem_close(sem);
}

int main() {
    // ... 过程的其它操作 ...
    writeToLog("Log message from Process");
    return 0;
}

匿名信号量和有名信号量 API 接口区别:

5. 共享内存(Shared Memory)

概念
在 Linux 中,共享内存是过程间通信(IPC)的一种模式。当多个过程须要拜访雷同的数据时,应用共享内存是一种高效的形式。它容许两个或多个过程拜访同一个物理内存区域,这使得数据传输不须要通过内核空间,从而进步了通信效率。

在解说共享内存前,咱们须要理解内存映射技术?

内存映射技术(Memory Mapping) 是一种将文件或设施的数据映射到过程内存地址空间的技术,它容许过程间接对这部分内存进行读写操作,就像拜访一般内存一样。这种技术不仅能够用于文件 I / O 操作,进步文件拜访效率,而且是实现共享内存的根底。

在 Linux 零碎中,内存映射能够通过 mmap 零碎调用来实现。mmap 容许将文件映射到过程的地址空间,也能够用来创立匿名映射(即不基于任何文件的共享内存区域)。

在 Linux 中,共享内存能够分为如下几类。

匿名共享内存

工作原理

匿名共享内存不与任何具体的文件系统文件间接关联,其内容仅在内存中存在。这意味着当所有应用它的过程都完结时,该内存区域的数据就会隐没。这种个性使得匿名共享内存非常适合于那些须要长期共享数据但又不须要将数据长久存储到磁盘的场景。

简略图解:

留神 :在 Linux 中, 匿名共享内存次要被设计用于有亲缘关系的过程间通信,如父子过程间。这是因为匿名共享内存的援用(例如,通过 mmap 创立时返回的内存地址)不会主动呈现在其余过程中,而是须要通过某种过程间通信的形式(如 Unix 域套接字)传递给相干的过程。而通过 Unix 域套接字来实现又稍显简单,所以咱们个别举荐匿名共享内存实用于有亲缘关系的过程间通信。

创立和应用

在 Linux 零碎中,匿名共享内存通常是通过 mmap()函数创立的,调用时需指定 MAP_ANONYMOUS 标记。此外,还须要设置 PROT_READ 和 PROT_WRITE 权限,以确保内存区域可读写。创立时也能够抉择 MAP_SHARED 标记,以便在多个过程间共享这块内存。

示例代码片段如下:

#include <sys/mman.h>
void* shared_memory = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOU

在这里,size 是心愿映射的内存区域大小,mmap()调用胜利后,返回指向共享内存区域的指针。

应用场景

大量数据交换:当两个或多个过程须要替换大量数据时,应用共享内存比传统的过程间通信办法(如管道或音讯队列)更有效率。

而谈到共享内存,又不得不探讨下对于共享内存的同步问题?

在应用共享内存时,因为多个过程能够间接并且同时拜访同一个物理内存区域,不加以适当管制就可能引起数据竞态和一致性问题。

数据竞态:当多个过程尝试同时批改共享内存中的同一数据项时,最终后果可能依赖于各过程操作的具体程序,可能导致不合乎预期的后果。

一致性问题:在没有适合同步机制的状况下,一个过程可能在另一个过程写入数据的同时读取共享内存,导致获取到不残缺或不统一的数据。

解决策略:应用信号量

信号量是一种罕用的同步机制,用于管制对共享资源的并发拜访。通过减少(开释资源)或缩小(占用资源)信号量的值,能够无效地管制对共享内存区域的拜访,避免数据竞态和确保数据一致性。

应用信号量来解决匿名共享内存同步问题的简略示例

int main() {
    // 创立或关上有名信号量
    sem_t *sem = sem_open("/mysemaphore", O_CREAT, 0666, 1);
    if (sem == SEM_FAILED) {
        // 错误处理,退出程序
        perror("sem_open failed");
        exit(EXIT_FAILURE);
    }

    // 创立匿名共享内存
    void* shared_memory = mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);
    if (shared_memory == MAP_FAILED) {// 错误处理,退出程序}
    int* counter = (int*)shared_memory;
    *counter = 0; // 初始化计数器

    pid_t pid = fork();
    if (pid == 0) {
        // 子过程
        for (int i = 0; i < 10; ++i) {sem_wait(sem); // 期待信号量
            (*counter)++;
            printf("Child process increments counter to %d\n", *counter);
            sem_post(sem); // 开释信号量
            sleep(1); // 暂停一段时间,模仿工作负载
        }
        exit(0);
    } else if (pid > 0) {
        // 父过程
        for (int i = 0; i < 10; ++i) {sem_wait(sem);
            printf("Parent process reads counter as %d\n", *counter);
            sem_post(sem);
            sleep(1);
        }
    } else {
        // fork 失败
        perror("fork failed");
        exit(EXIT_FAILURE);
    }

    // 清理资源
    if (pid > 0) { // 父过程期待子过程实现
        wait(NULL);
        sem_close(sem);
        sem_unlink("/mysemaphore");
        munmap(shared_memory, sizeof(int));
    }
    return 0;
}

基于文件的共享内存

工作原理:

基于文件的共享内存通过将磁盘上的理论文件映射到一个或多个过程的地址空间中来实现。当文件被映射到内存后,过程就能够像拜访一般内存一样间接读写文件内容,操作系统负责同步内存批改回磁盘文件。这种机制既进步了数据拜访的效率,也实现了数据的长久化存储。

相比匿名共享内存只能适宜有亲缘关系的过程,基于文件的共享内存特地适宜于实现非亲缘关系过程间的数据共享

简略图解:

创立和应用:

要创立基于文件的共享内存,首先须要关上(或创立)一个文件,而后应用 mmap()将文件映射到内存中。与匿名共享内存不同,这里须要提供 文件描述符 而不是 MAP_ANONYMOUS 标记。

示例代码片段如下:

#include <sys/mman.h>
#include <fcntl.h>

size_t size = 4096; // 共享内存区域大小

int fd = open("shared_file", O_RDWR | O_CREAT, 0666);
ftruncate(fd, size); // 设置文件大小
void* shared_memory = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);

在这里,shared_file 是被映射的文件名,size 是文件的预期大小。通过 ftruncate() 调整文件大小以匹配共享内存的需要。mmap()胜利后返回指向共享内存区域的指针。

应用场景:

大量数据交换:基于文件的共享内存同样实用于多个过程须要进行大量数据交换的场景。与匿名共享内存不同的是,这些数据能够长久化存储到磁盘上。

在应用基于文件的共享内存时,同样须要解决多个过程共享数据的同步问题,以保证数据的一致性和完整性。

解决方案

  • 信号量
    信号量能够了解是一个计数器,用来管制同时访问共享资源(如共享内存)的过程数量。如果信号量计数大于 0,示意资源可用,过程能够拜访资源并将计数减 1;如果信号量计数为 0,示意资源不可用,过程必须期待。当资源应用结束后,过程会减少信号量计数,示意资源再次可用。
  • 文件锁
    文件锁容许过程对共享内存所基于的文件加锁,避免其余过程同时拜访。如果一个过程要写入共享内存,它能够加一个排他锁,这时其余过程既不能读也不能写;如果只须要读取,过程能够加一个共享锁,这样其余过程也能够加共享锁来读取数据,但不能写入。在 Linux 中,文件锁的实现次要依赖于两个零碎调用:fcntl 和 flock。而对于 fcntl 和 flock 的解说,我在前文也有提到过。

简略来说

  • 应用信号量是为了确保在同一时间只有限定数量的过程能够操作共享内存。
  • 应用文件锁是为了避免在某个过程读写共享内存时,其余过程进行烦扰。

上面来看一个应用 有名信号量解决基于文件的共享内存同步问题的示例,这个简略的示例演示了两个过程:一个过程向共享内存写入数据,另一个过程从共享内存读取数据。这两个过程应用同一个有名信号量来同步对共享内存区域的拜访。

示例代码:

首先,确保你有一个名为 shared_file 的文件和一个名为 /mysemaphore 的信号量。

写入过程

int main() {
    const char* filename = "shared_file";
    const size_t size = 4096;
    // 关上文件
    int fd = open(filename, O_RDWR);
    if (fd < 0) {
        // 错误处理并退出
        perror("open");
        exit(EXIT_FAILURE);
    }
    // 映射文件
    void* addr = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
    if (addr == MAP_FAILED) {// 错误处理并退出}
    // 关上有名信号量
    sem_t *sem = sem_open("/mysemaphore", O_CREAT, 0666, 1);
    if (sem == SEM_FAILED) {// 错误处理并退出}
    // 期待信号量,开始写入数据
    sem_wait(sem);
    strcpy((char*)addr, "Hello, Shared Memory!");
    sem_post(sem);
    // 清理
    munmap(addr, size);
    close(fd);
    sem_close(sem);
    return 0;
}

读取过程:

int main() {
    const char* filename = "shared_file";
    const size_t size = 4096;
    // 关上文件
    int fd = open(filename, O_RDONLY);
    if (fd < 0) {
        // 错误处理并退出
        perror("open");
        exit(EXIT_FAILURE);
    }
    // 映射文件
    void* addr = mmap(NULL, size, PROT_READ, MAP_SHARED, fd, 0);
    if (addr == MAP_FAILED) {// 错误处理并退出}
    // 关上有名信号量
    sem_t *sem = sem_open("/mysemaphore", O_CREAT, 0666, 1);
    if (sem == SEM_FAILED) {// 错误处理并退出}
    // 期待信号量,开始读取数据
    sem_wait(sem);
    printf("Read from shared memory: %s\n", (char*)addr);
    sem_post(sem);
    // 清理
    munmap(addr, size);
    close(fd);
    sem_close(sem);
    return 0;
}

阐明:下面的信号量初始值为 1,实际上信号量在这里充当的就是互斥锁。

Posix 共享内存

POSIX 共享内存提供了一种高效的形式,容许多个过程通过共享内存区域进行通信。与基于文件的共享内存相比,POSIX 共享内存不须要间接映射磁盘上的文件,而是通过创立命名的共享内存对象来实现过程间的数据共享。这些对象尽管在逻辑上相似于文件(因为能够通过 shm_open 创立和关上),但本质上间接存在于内存中,提供了更快的数据访问速度。

Posix 共享内存接口

shm_open()        // 创立或关上一个共享内存对象
shm_unlink()      // 删除一个共享内存对象的名称
ftruncate()       // 调整共享内存对象的大小
mmap()            // 将共享内存对象映射到调用过程的地址空间
munmap()          // 解除共享内存对象的映射

示例演示

#define SHM_NAME "/example_shm"
#define SHM_SIZE 4096
int main() {
    int shm_fd;
    void *ptr;
    // 创立共享内存对象
    shm_fd = shm_open(SHM_NAME, O_CREAT | O_RDWR, 0666);
    if (shm_fd == -1) {
        // 错误处理并退出
        perror("shm_open");
        return 1;
    }
    // 设置共享内存大小
    if (ftruncate(shm_fd, SHM_SIZE) == -1) {// 错误处理并退出}
    // 映射共享内存
    ptr = mmap(0, SHM_SIZE, PROT_WRITE, MAP_SHARED, shm_fd, 0);
    if (ptr == MAP_FAILED) {// 错误处理并退出}
    // 写入数据到共享内存
    const char *message = "Hello, POSIX Shared Memory!";
    sprintf(ptr, "%s", message);
    printf("Data written to shared memory: %s\n", message);
    // 解除映射
    munmap(ptr, SHM_SIZE);
    // 敞开共享内存对象
    close(shm_fd);
    return 0;
}

System V 共享内存

System V 共享内存是一种传统的过程间通信(IPC)机制,它容许多个过程通过共享内存区域进行通信。与 POSIX 共享内存不同,System V 共享内存应用 IPC 键值 key_t 来标识和治理共享内存段,而不是通过命名的形式。这种机制提供了一套底层管制共享内存的 API,容许进行更细粒度的操作,如权限管制、共享内存状态的查问和治理等。

System V 共享内存接口

shmget()         // 创立或获取共享内存段的标识符
shmat()          // 将共享内存段附加到过程的地址空间
shmdt()          // 拆散共享内存段和过程的地址空间
shmctl()         // 对共享内存段执行管制操作

示例演示

#include <sys/ipc.h>
#include <sys/shm.h>
int main() {key_t key = ftok("somefile", 65); // 创立 IPC 键
    int shm_id;
    void *ptr;
    // 创立共享内存段
    shm_id = shmget(key, 1024, 0666|IPC_CREAT);
    if (shm_id < 0) {perror("shmget");
        return -1;
    }
    // 将共享内存段附加到过程的地址空间
    ptr = shmat(shm_id, (void*)0, 0);
    if (ptr == (void*) -1) {perror("shmat");
        return -1;
    }
    // 在共享内存上操作,例如写入数据
    // 示例:写入一个字符串
    strcpy(ptr, "Hello, System V Shared Memory!");
    // 拆散共享内存段
    if (shmdt(ptr) < 0) {perror("shmdt");
        return -1;
    }
    // 删除共享内存段
    shmctl(shm_id, IPC_RMID, NULL);
    return 0;
}

6. 音讯队列 (Message Queues)

概念

音讯队列是一种容许一个或多个过程向其写入音讯,并由一个或多个过程读取音讯的 IPC 机制。每条音讯都由一个音讯队列标识符(ID)辨认,且能够携带一个特定的类型。音讯队列容许不同过程非阻塞地发送和接管记录或数据块,这些记录能够是不同类型和大小的。

音讯队列图解:

应用场景

  • 过程间通信:
    在波及多个运行过程的利用中,音讯队列提供了一种高效的形式来传递信息。它容许过程之间无需间接相互连接就能替换数据,从而简化了通信过程。
  • 异步数据处理:
    音讯队列使过程可能异步解决信息。一个过程(即生产者)能够发送工作或数据至队列,并持续其余操作,而另一过程(即消费者)能够在准备就绪时从队列中取出并解决这些数据。这种模式无效地拆散了数据的生成和生产过程,进步了利用的效率和响应速度。理论的利用比方:日志记录,某些零碎可能有一个专门的过程负责记录日志,其余过程能够将日志音讯发送到音讯队列,由该专门过程异步地写入日志文件。

以下是应用 System V IPC 音讯队列的一个简略示例:

struct message {
    long mtype;
    char mtext[100];
};

// 发送音讯至音讯队列
int main() {key_t key = ftok("queuefile", 65);  // 生成惟一键
    int msgid = msgget(key, 0666 | IPC_CREAT); // 创立音讯队列
    struct message msg;
    msg.mtype = 1; // 设置音讯类型
    sprintf(msg.mtext, "Hello World"); // 音讯内容
    msgsnd(msgid, &msg, sizeof(msg.mtext), 0); // 发送音讯
    printf("Sent message: %s\n", msg.mtext);
    return 0;
}

// 从音讯队列中获取音讯
int main() {key_t key = ftok("queuefile", 65);
    int msgid = msgget(key, 0666 | IPC_CREAT);
    struct message msg;
    msgrcv(msgid, &msg, sizeof(msg.mtext), 1, 0); // 接管音讯
    printf("Received message: %s\n", msg.mtext);
    msgctl(msgid, IPC_RMID, NULL); // 销毁音讯队列
    return 0;
}

7. 套接字 (Sockets)

概念

套接字是一种在不同过程间进行数据交换的通信机制。在 Linux 中,套接字能够用于同一台机器上的过程间通信(IPC)或不同机器上的网络通信。套接字反对多种通信协议,最常见的是 TCP(牢靠的、连贯导向的协定)和 UDP(无连贯的、不牢靠的协定)。

简略图解:

应用场景:

网络通信
同一台主机或不同主机上的过程之间通过网络套接字进行数据交换。

简略示例: – 应用 TCP 套接字进行通信

// 服务器端(监听和接收数据):
int main() {
    int server_fd, new_socket;
    struct sockaddr_in address;
    int addrlen = sizeof(address);
    char buffer[1024] = {0};
    server_fd = socket(AF_INET, SOCK_STREAM, 0);  // 创立套接字
    // 定义套接字地址
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(8080);
    // 绑定套接字
    bind(server_fd, (struct sockaddr *)&address, sizeof(address));
    listen(server_fd, 3);    // 监听套接字
    while(1) {
        // 承受连贯
        printf("Waiting for a connection...\n");
        new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen);
        // 读取数据
        read(new_socket, buffer, 1024);
        printf("Message: %s\n", buffer);
        // 能够在这里解决收到的音讯或执行其余工作
        close(new_socket);  // 敞开这次连贯的套接字
    }

    // 敞开监听的套接字
    // 留神:因为 while(1),这行代码不会执行,除非在循环中退出退出条件
    close(server_fd);
    return 0;
}

// 客户端过程(发送数据):
int main() {
    int sock = 0;
    struct sockaddr_in serv_addr;
    sock = socket(AF_INET, SOCK_STREAM, 0); // 创立套接字
    serv_addr.sin_family = AF_INET; // 定义套接字地址
    serv_addr.sin_port = htons(8080);
    connect(sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr));    // 连贯到服务器
    
    // 发送数据
    char *message = "Hello from the client!";
    send(sock, message, strlen(message), 0);
    close(sock);
    return 0;
}

8. 域套接字 (Unix Domain Sockets)

概念

域套接字(Unix Domain Sockets)是一种在同一台机器上的过程间进行数据通信的机制。绝对于网络套接字,它们提供了更高效的本地通信形式,因为数据不须要通过网络协议栈。域套接字反对流(相似 TCP)和数据报(相似 UDP)两种模式。

特地阐明:在域套接字通信中,“不通过网络协议栈” 指的是数据传输不须要 IP 层的路由、不须要 TCP/UDP 等传输层协定的封包与解包解决,也不须要网络接口层的参加。这一点与网络套接字不同,后者用于跨网络的通信,须要通过残缺的网络协议栈解决,包含数据的封装、传输、路由和解封装等。

简略图解:

应用场景:

  • 本地过程间通信

    当须要在同一台机器上的不同过程间高效地替换数据时。

  • 代替管道和音讯队列

    当须要比管道和音讯队列更简单的双向通信时。

简略示例: – 应用 Unix 域套接字进行通信

// 服务器端(监听和接收数据):
int main() {
    int server_fd, client_socket;
    struct sockaddr_un address;
    server_fd = socket(AF_UNIX, SOCK_STREAM, 0);  // 创立套接字
    address.sun_family = AF_UNIX;     // 设置套接字地址
    strcpy(address.sun_path, "/tmp/unix_socket");
    // 绑定和监听
    bind(server_fd, (struct sockaddr *)&address, sizeof(address));
    listen(server_fd, 5);
    while(1){
        // 承受连贯
        client_socket = accept(server_fd, NULL, NULL);

        // 解决数据
        char buffer[100];
        read(client_socket, buffer, 100);
        printf("Received: %s\n", buffer);
        // 进行其余的业务解决
        // ...
        
        close(client_socket);
    }
    // 清理
    close(server_fd);
    unlink("/tmp/unix_socket");
    return 0;
}

// 客户端(发送数据):
int main() {
    int sock;
    struct sockaddr_un address;
    sock = socket(AF_UNIX, SOCK_STREAM, 0); // 创立套接字
    address.sun_family = AF_UNIX;           // 设置套接字地址
    strcpy(address.sun_path, "/tmp/unix_socket");
    // 连贯到服务器
    connect(sock, (struct sockaddr *)&address, sizeof(address));
    // 发送数据
    char *message = "Hello from the client!";
    write(sock, message, strlen(message));
    // 清理
    close(sock);
    return 0;
}

注意事项

  • Unix 域套接字的地址是文件系统中的门路,而不是 IP 地址和端口。
  • Unix 域套接字通常用于同一台机器上的过程间通信,而不适用于网络通信。
  • 应用 Unix 域套接字时,须要确保套接字文件的门路是可拜访的,并在通信实现后清理套接字文件。

Linux 线程

什么是线程?

线程,有时被称为“轻量级过程”,是程序执行流的最小单位。它容许多任务在单个过程外部并发执行。

线程与过程的区别:

  • 过程: 领有独立的地址空间和资源。
  • 线程: 共享其所在过程的资源,但有本人的堆栈空间。

创立你的第一个线程

在 Linux 下,咱们应用 POSIX Threads(简称 Pthreads)库来操作线程。以下是一个简略的例子,创立并运行两个线程:

#include <stdio.h>
#include <pthread.h>

// Thread 1 function
void* func1(void* arg) {printf("Hello from thread 1!\n");
    return NULL;
}

// Thread 2 function
void* func2(void* arg) {printf("Hello from thread 2!\n");
    return NULL;
}

int main() {
    pthread_t thread1, thread2;

    pthread_create(&thread1, NULL, func1, NULL);    // Create thread 1
    pthread_create(&thread2, NULL, func2, NULL);    // Create thread 2

    pthread_join(thread1, NULL); // Wait for thread 1 to finish
    pthread_join(thread2, NULL); // Wait for thread 2 to finish
    return 0;
}

线程同步:何时应用?

当两个或多个线程想要拜访同一个资源时,问题就来了!如何确保资源的平安拜访?有以下三种线程同步的形式。

  • 互斥锁: 一个线程在应用资源时,锁住它,其余线程期待。个别用在临界区的爱护。
  • 条件变量: 线程期待直到某个条件满足。个别和互斥锁搭配应用来实现线程同步。
  • 信号量: 一种高级的同步形式,能够管制资源的拜访数量。

    信号量更为通用,因为它不仅能够用作互斥锁,还能够用来同步线程,例如:确保线程按特定的程序执行或管制对无限资源的拜访。

确保线程按特定的程序执行

在某些场景下,您可能心愿线程以特定的程序执行。例如,线程 A 必须在线程 B 之前执行。这能够通过应用信号量来实现。

管制对无限资源的拜访

信号量也可用于管制对无限资源的拜访。例如,数据库连接池,其中只有肯定数量的连贯可供线程应用,能够应用信号量来确保只有固定数量的线程能够同时拜访这些资源。

确保线程按特定的程序执行的示例代码

sem_t semA;
// 线程 A
void* threadA(void* arg) {printf("Thread A is running\n");
    sem_post(&semA); // 开释信号量 A
    return NULL;
}

// 线程 B
void* threadB(void* arg) {sem_wait(&semA); // 期待信号量 A
    printf("Thread B is running\n");
    return NULL;
}

int main() {
    pthread_t tA, tB;
    // 初始化信号量
    sem_init(&semA, 0, 0);
    // 创立线程
    pthread_create(&tA, NULL, threadA, NULL);
    pthread_create(&tB, NULL, threadB, NULL);
    // 期待线程完结
    pthread_join(tA, NULL);
    pthread_join(tB, NULL);
    // 清理资源
    sem_destroy(&semA);
    return 0;
}

线程的长处与毛病:

长处:

  • 线程之间的切换老本比过程之间的切换成本低。
  • 线程间的通信速度比过程间的通信速度快,因为线程共享同一地址空间。
  • 利用多线程能够很容易地在单过程利用中实现并发。

毛病:

  • 因为线程共享同一地址空间,一个线程的谬误可能会毁坏其余线程的数据或状态。
  • 须要简单的同步操作来防止竞争条件。

常见问题与挑战

死锁:

死锁产生在两个或多个线程永恒地期待对方开释锁的状况。它通常产生在多个线程须要多个锁时,如果不按雷同的程序获取锁,就可能陷入相互期待的状态。

解决方案

  • 确保所有线程以雷同的程序获取锁。
  • 应用层次结构的锁定零碎,其中线程必须按特定程序获取锁。
  • 设置超时,以便在期待锁的工夫过长时,线程能够放弃期待,尝试其余操作。

线程平安

线程平安是指确保代码能够在多线程环境中平安运行,不会因为多个线程同时访问共享资源而导致数据损坏或不统一。

解决方案:

  • 应用同步机制,如互斥锁或信号量,来管制对共享资源的拜访。
  • 编写无状态的代码,或者确保状态信息不在多个线程间共享。 无状态的代码指的是不保留任何与特定实例相干的数据(状态)的代码。在多线程环境中,这意味着代码不依赖于或不批改任何内部状态,如全局变量或类的成员变量。
  • 应用不可变对象,这些对象一旦创立就不会更改,因而能够平安地在多个线程间共享。不可变对象是指一旦被创立就不能被批改的对象(如字符串),这些对象的状态在创立后是固定的,因而在多线程环境中平安。

总结:编写无状态的代码和应用不可变对象都是防止多线程环境中的数据抵触和竞争条件的策略。无状态代码防止了共享数据,而不可变对象则确保了即便数据被共享,它们也不会被批改,从而保障线程平安。

进一步摸索

线程池:

线程池通过重用一组事后创立的线程来解决工作,缩小了线程创立和销毁的开销。

利用:线程池宽泛用于网络服务器利用,特地是在须要解决大量短暂工作的场景中。

高级同步原语:

读写锁(Read-Write Locks)

读写锁是一种非凡类型的锁,它容许多个线程同时读取共享资源,但写入操作须要独占拜访。这意味着只有没有线程正在写入共享资源,多个线程能够同时读取资源而不会被阻塞。

利用场景:实用于读操作远多于写操作的状况,比方缓存零碎。

长处:进步了在读多写少场景下的并发性能。

实现:在 POSIX 线程库中,通过 pthread_rwlock_t 类型提供。

简略示例

pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;
void* reader(void* arg) {pthread_rwlock_rdlock(&rwlock);
    printf("Reader is reading...\n");
    sleep(1); // 模仿读取操作
    pthread_rwlock_unlock(&rwlock);
    return NULL;
}
void* writer(void* arg) {pthread_rwlock_wrlock(&rwlock);
    printf("Writer is writing...\n");
    sleep(1); // 模仿写入操作
    pthread_rwlock_unlock(&rwlock);
    return NULL;
}
int main() {
    pthread_t t1, t2;
    pthread_rwlock_init(&rwlock, NULL);
    pthread_create(&t1, NULL, reader, NULL);
    sleep(2); // 确保读者先运行
    pthread_create(&t2, NULL, writer, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_rwlock_destroy(&rwlock);
    return 0;
}

屏障(Barriers)

屏障用于同步多个线程在程序中的特定点。当线程达到一个屏障时,它会期待,直到所有其余线程也都达到这个屏障。而后所有线程能力继续执行。

利用场景:用于并行算法,确保所有线程实现某个阶段的工作后才开始下一个阶段。

长处:确保所有线程同步进行,防止数据不统一。

实现:在 POSIX 线程库中,通过 pthread_barrier_t 类型提供。

简略示例

#define NUM_THREADS 5
pthread_barrier_t barrier;
void* task(void* arg) {printf("Thread %ld waiting at barrier\n", (long)arg);
    pthread_barrier_wait(&barrier);
    printf("Thread %ld passed barrier\n", (long)arg);
    return NULL;
}

int main() {pthread_t threads[NUM_THREADS];
    pthread_barrier_init(&barrier, NULL, NUM_THREADS);
    for (long i = 0; i < NUM_THREADS; i++) {pthread_create(&threads[i], NULL, task, (void*)i);
    }
    for (int i = 0; i < NUM_THREADS; i++) {pthread_join(threads[i], NULL);
    }
    pthread_barrier_destroy(&barrier);
    return 0;
}

阐明:这个程序演示了如何应用屏障来同步多个线程,确保所有线程都达到一个执行点后才一起继续执行。在这个例子中,所有线程都会在打印“期待”信息后期待,直到它们全副达到 pthread_barrier_wait 调用处。只有当所有线程都达到这个点时,它们才会继续执行并打印“通过”信息。

原子操作(Atomic Operations)

原子操作是指在多线程环境中,一系列操作作为一个独自的不可中断的单位执行,确保在读取、批改和更新变量时的原子性。这些操作在执行的全过程中不会被线程调度机制中断。

利用场景

非常适合于计数器、标记位更新等简略状态的更新场景,其中对繁多变量的读取、批改和更新必须作为一个整体来执行,以防止数据竞争和保证数据一致性。

长处

  • 效率:相比锁机制,原子操作通常更高效,因为它们防止了锁的开销和潜在的上下文切换。
  • 简化编程模型:对于简略的同步需要,原子操作提供了一种简略间接的解决方案,防止了应用锁的复杂性。

实现:在 POSIX 线程库中,原子操作并非间接提供,但能够通过 GCC 提供的内建原子操作函数,如__sync_fetch_and_add、__sync_lock_test_and_set 等。C++11 及更高版本的规范也提供了原子操作的反对,如 std::atomic 类型。

简略示例

// 定义一个全局计数器
volatile int counter = 0;
// 线程函数,用于减少计数器
void* increment_counter(void* arg) {
    int i;
    for (i = 0; i < 10000; ++i) {
        // 应用 GCC 的内建原子操作函数进行原子减少
        __sync_fetch_and_add(&counter, 1);
    }
    return NULL;
}
int main() {
    pthread_t t1, t2;
    // 创立两个线程,都执行 increment_counter 函数
    pthread_create(&t1, NULL, increment_counter, NULL);
    pthread_create(&t2, NULL, increment_counter, NULL);
    // 期待线程实现
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    // 打印最终的计数器值
    printf("Final counter value: %d\n", counter);
    return 0;
}

自旋锁(Spinlocks)

自旋锁是一种忙期待的锁,当一个线程尝试获取一个曾经被其余线程持有的锁时,它会在一个循环中一直查看锁的状态。这意味着线程会始终占用 CPU,直到它可能获取到锁。

利用场景

特地适宜锁持有工夫十分短的场景,因为它防止了线程从运行态转为期待态的开销,这在多核处理器上尤其有用。

实现:在 POSIX 线程库中,自旋锁通过 pthread_spinlock_t 类型提供,相干的操作包含 pthread_spin_lock、pthread_spin_unlock 等。自旋锁的应用和治理绝对简略,但须要审慎应用以防止适度占用 CPU 资源。

简略示例

pthread_spinlock_t spinlock;
void* task(void* arg) {pthread_spin_lock(&spinlock);
    printf("Thread %ld got the lock\n", (long)arg);
    sleep(1); // 模仿工作执行
    pthread_spin_unlock(&spinlock);
    return NULL;
}
int main() {
    pthread_t t1, t2;
    pthread_spin_init(&spinlock, PTHREAD_PROCESS_PRIVATE);
    pthread_create(&t1, NULL, task, (void*)1L);
    pthread_create(&t2, NULL, task, (void*)2L);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_spin_destroy(&spinlock);
    return 0;
}

内存治理入门

在后面的解说中,咱们曾经学习了过程和线程的基本概念,理解了它们是操作系统进行资源分配和任务调度的根本单位。而无论是过程还是线程,它们的运行都离不开一个要害的系统资源——内存。这天然引出了一个重要的问题:操作系统是如何治理这些内存资源的?这正是咱们接下来要探讨的主题— Linux 内存治理

内存调配与开释

首先,咱们先来看下内存的调配与开释,常见的内存调配形式蕴含以下两种:

动态内存调配:是在编译时实现的,通常用于固定大小的数据结构,比方:一般数组。

动态内存调配:则在运行时进行,容许程序依据须要调配任意大小的内存块,比方:动静数组。

咱们个别应用 malloc 和 free 来进行动态内存调配与开释。

来看个动态内存调配的例子:

#include <stdio.h>
#include <stdlib.h>

int main() {int *array = malloc(10 * sizeof(int)); # 分配内存
    if (array == NULL) {perror("malloc failed");
        return EXIT_FAILURE;
    }
    #应用动态内存 array ...

    free(array); # 开释内存
    return 0;
}

内存泄露

应用程序如果没有正确的治理内存的调配与回收,就有可能呈现内存透露,重大点的有可能导致程序异样退出。

那什么是内存泄露?

内存泄露是指程序中动态分配的内存没有及时开释,导致这部分内存在程序执行过程中始终占用,无奈被再次利用。在长时间运行的程序中,内存泄露可能会导致内存应用一直减少,最终耗尽所有可用内存,影响程序性能甚至引发程序解体。

如何防止内存泄露?

  • 正当设计程序结构:确保每次 malloc 后都有对应的 free 操作。能够通过应用自动化工具,如 Valgrind 等,来检测程序运行中的内存泄露问题。
  • 应用智能指针:在反对 C++ 等高级语言中,应用智能指针(如 std::unique_ptr, std::shared_ptr 等)能够帮忙治理动态内存的生命周期,智能指针会在适当的时候主动开释内存。
  • 及时开释内存:在不须要动态分配的内存后,应立即开释。尤其是在异样解决、错误处理的代码门路中,也不要遗记开释内存。
  • 规范化资源管理 :应用 RAII(Resource Acquisition Is Initialization)准则治理资源, 确保资源的获取即是初始化,随着对象的销毁资源被开释

虚拟内存治理

虚拟内存概念:

虚拟内存是计算机系统内存治理的一种技术。它使得应用程序认为它领有很大间断的、可用的内存空间,即便这些内存可能被扩散存储在物理内存和磁盘上。

虚拟内存的次要益处是

  • 它提供了比理论物理内存更大的地址空间。
  • 保障每个程序在内存中有一个间断的地址空间。
  • 容许零碎运行大于物理内存的程序。
  • 通过内存隔离,进步了程序间的安全性。

操作系统通过应用硬盘上的一块称为“替换空间”的区域来实现这一点,它作为物理内存的一个扩大。当零碎的物理 RAM 有余时,它能够将以后不沉闷的内存页面挪动到磁盘上,从而为须要更多内存的过程腾出空间。

分页机制

分页是虚拟内存治理中最罕用的技术之一。它将虚拟内存和物理内存分成大小相等的块,这些块在虚拟内存中被称为“页”(pages),在物理内存中被称为“页框”(page frames)。每个程序都有一个页表,页表将程序的虚构地址映射到物理内存的页框。

分页机制如何工作:

1. 当程序试图拜访虚拟内存中的地址时,它首先会查看页表。

2. 如果找到了对应的物理地址,那么数据的存取操作就会持续。

3. 如果没有找到,会触发一个缺页中断,由操作系统解决。

缺页中断

缺页中断(Page Fault)是分页零碎中的一项要害机制,当一个过程拜访的虚构页不在物理内存中时触发。这时候,操作系统会调配一个物理页框,并将该虚构页所对应的磁盘数据加载至页框中,并在页表中建设虚构页和物理页的映射关系。这样,当下一次过程在拜访雷同虚构页的时候,就能够间接拜访内存中的数据了。

通过以上机制,虚拟内存治理提供了高效灵便的内存应用形式,容许操作系统优化内存调配,同时也给应用程序提供了简略的内存治理模型。

文件系统:摸索 Linux 中的数据管理

后面咱们探讨了 Linux 零碎中的内存治理,包含内存调配与开释、内存透露和虚拟内存等概念,这些都是操作系统保障程序失常运行的根底。内存治理使得多个利用可能高效、平安地共享零碎的物理内存资源,同时还提供了数据的长期存储能力。然而,内存只能提供长期存储,当零碎断电或重启时,内存中的数据就会失落。这就引出了咱们下一个重要话题:文件系统

在谈文件系统之前,咱们先来理解下虚构文件系统。

虚构文件系统(VFS)

什么是 VFS?

Linux 内核中的虚构文件系统(VFS)是一个要害的形象层,它为各种不同的文件系统提供了一个对立的操作接口。这意味着,不论数据实际上存储在哪个文件系统中(比方 EXT4、XFS 等),VFS 都能提供统一的拜访形式。

VFS 的作用

兼容性:使得不同的文件系统都能在 Linux 上工作。

统一性:它为应用程序提供了一个规范的文件操作接口,简化了文件拜访和治理。

接下来让咱们来看下文件系统。

Linux 的文件系统是什么?

Linux 文件系统是 Linux 操作系统用于存储、治理和拜访文件和目录的一套规定和构造。它提供了一个层次化的目录构造,让用户和程序可能以统一的形式组织和拜访数据。Linux 文件系统反对多种类型,如 EXT4、XFS 和 Btrfs,每种都有其特定的劣势和用处。文件系统管理文件的存储细节,包含文件的创立、读取、写入和删除操作,同时也解决文件的权限和安全性。通过虚构文件系统(VFS)层,Linux 可能提供一个对立的接口来拜访这些不同的文件系统,使得文件操作对用户和应用程序通明。

文件系统外围组件:

超级块(Superblock)

超级块是文件系统的元数据的一部分,它蕴含了对于整个文件系统的全局信息,如文件系统的类型、大小、状态、闲暇和已用的块和 Inode 数量等。超级块的次要作用是提供文件系统的要害信息,以便操作系统可能正确地治理和拜访文件系统。

Inode

Inode 是文件系统中的一个要害数据结构,每个文件和目录都有一个惟一的 Inode。它蕴含了文件的元数据(如文件大小、所有者、权限、工夫戳)和指向理论存储文件数据的数据块的指针。Inode 不存储文件名,文件名存储在目录文件中,这些目录文件将文件名映射到 Inode 号。inode 号是文件的惟一标识,而不是文件名。

目录项(Dentry)

目录项(或 Dentry 缓存)是内核用来保护文件名与其对应 Inode 之间映射的构造。目录项缓存是一个重要的性能优化机制,它缩小了从文件名到文件内容的查找时间。

文件数据块

文件数据块是存储文件理论内容的磁盘空间。Linux 文件系统将磁盘空间宰割成一系列的块,这些块能够间接被 Inode 指向,或者通过间接块来存储较大文件的数据。

文件和目录

文件和目录是用户与文件系统交互的根本单元。在 Linux 中,所有皆文件:传统的数据文件、目录、设施(如字符设施和块设施)等都通过文件或文件系统的接口来拜访。

上面是文件、目录、inode、以及数据块之间的映射关系图

我以程序拜访磁盘文件为例,来给大家阐明下具体的拜访过程,不便大家了解上述图示。

操作系统会执行以下几个步骤

  • 解析文件门路:操作系统首先解析残缺的文件门路,确定文件在文件系统中的地位。
  • 查找目录项:利用文件门路,操作系统在文件系统的目录构造中查找对应的目录项(Dentry)。目录项将文件名映射到一个惟一的 Inode 编号。
  • 拜访 Inode:每个文件都有一个 Inode,其中蕴含该文件的元数据(如所有者、权限)和指向文件理论数据块的指针。操作系统应用目录项提供的 Inode 编号来拜访 Inode Table,进而拜访对应的 inode。
  • 读取数据块:通过 Inode 中的信息,操作系统找到存储文件数据的磁盘块地位,而后读取这些数据块以获取文件内容。

除此之外,在 Linux 中,还存在两种非凡的援用文件的形式:硬链接和软链接

硬链接和软链接

什么是硬链接?

硬链接实际上是指标文件的另一个名称。它与原文件共享雷同的 inode 号,因而,无论通过哪个名称拜访,内容都是统一的。

图示

这里,“file1”和“link1”都是硬链接,它们指向同一个 inode。这意味着它们共享雷同的数据块和文件属性。

如何创立硬链接?

命令 ln 源文件 指标文件

例如,创立一个名为 file1 的文件的硬链接 link1,你能够应用:ln file1 link1。

特点

  • 硬链接不能跨文件系统。
  • 不能为目录创立硬链接。
  • 删除原始文件或硬链接中的任何一个不会影响其余文件,因为它们共享雷同的数据块。

什么是软链接?

与硬链接不同,软链接是一个独立的文件,它并不蕴含理论的文件内容,而是指向另一个文件或目录的门路。

图示

在这里,“link1”是一个指向“file1”的软链接。与硬链接不同,软链接只是一个指向另一个文件或目录的门路。当咱们拜访软链接时,零碎会主动重定向咱们到它所指向的理论文件。

如何创立软链接?

命令 ln -s 源文件 指标文件

例如,为 file1 创立一个软链接 link1,你能够应用:ln -s file1 link1。

特点

  • 软链接能够跨文件系统。
  • 能够为目录创立软链接。
  • 如果删除了指标文件,软链接会变为死链接,无奈再拜访原始内容。

总结

这篇文章次要是为想学习 Linux 零碎编程的初学者提供一个学习指南,从基本概念到高级性能,咱们不仅揭示了 Linux 零碎的核心技术和架构,还探讨了用户空间与内核空间的要害区别,零碎调用与库函数的根本了解,以及文件 IO 的多样化操作。咱们学习了过程和线程的根底,了解了它们之间的差别,以及如何无效地应用线程同步技术来编写稳固的多线程程序。此外,咱们还涵盖了内存治理的基础知识,从内存调配与开释到虚拟内存治理,最初学习了 Linux 文件系统的基本概念及其外围组件,以及硬链接和软链接的应用和区别。

无论你是刚开始接触 Linux 零碎编程的老手,还是心愿坚固现有常识的教训开发者,本文都提供了贵重指南。

通过本文的学习,我心愿读者可能:

  • 把握 Linux 零碎架构的要害组成部分,包含用户空间和内核空间的区别。
  • 了解零碎调用和库函数的作用,以及它们在零碎编程中的重要性。
  • 纯熟进行文件 IO 操作,包含文件描述符的应用,文件地位的挪动,以及高级文件 I / O 技术的利用。
  • 理解过程和线程的基本概念,包含它们的创立、终止和状态转换,以及过程间通信的办法。
  • 把握线程同步的技巧,理解线程的优缺点以及在理论编程中的利用。
  • 建设内存治理的基本知识框架,包含内存调配开释、虚拟内存治理以及如何防止内存泄露。
  • 摸索 Linux 文件系统,了解虚构文件系统(VFS)的概念,以及硬链接和软链接的应用和区别。

最初:

如果你对 Linux 零碎编程以及计算机根底相干的常识感兴趣,无妨关注我的公众号—「跟着小康学编程」。这里会定时更新相干的技术文章,感兴趣的读者能够关注一下:

另外,小康最近新创建了一个技术交换群,大家如果在浏览的过程中有遇到问题或者有不了解的中央,欢送大家加群询问或者评论区询问,我能解决的都尽可能给大家回复。

扫一扫小康的集体微信,备注「加群」即可。

本文由 mdnice 多平台公布

正文完
 0