普通视图

发现新文章,点击刷新页面。
昨天以前xStack

【xv6】Copy on write fork()

作者 Noicdi
2022年12月4日 17:00

fork() 的缺点

在日常的 Linux 编程中,一般会通过 fork() 创建一个新进程,分为父子进程来处理逻辑;或者在子进程中通过 exec() 来加载一个新程序,shell 启动新程序就是这样操作的。在执行 fork() 时,子进程会完全拷贝父进程,包括其执行代码段、数据段、栈和堆,并在接下来的处理过程中修改数据段等内存空间中的数据,而不影响另一个进程。

然而这样的直接拷贝,对于内存来说是一种浪费。执行 fork() 后子进程可能只是简单的执行了一些动作;或者直接调用 exec() 将新程序替换到代码段,重新初始化数据段和堆栈。那 fork() 时的完整拷贝又有什么意义呢?

Copy on write

基于以上以及其他缘由,开发者们提出了「Copy on write」机制。

在 COW 机制之前,执行 fork() 后会在 kernel 中初始化子进程的数据结构,并逐页复制父进程的物理页块的内容到自己的页块上,并在页表中将虚拟地址映射到物理地址上。

而 COW 机制不会逐页复制父进程的物理页块,而是直接在页表中将虚拟地址映射到父进程的物理地址上,即父子进程共享同一物理地址。同时将物理页块设置为不可读,防止某一进程修改影响到其他进程。当某一个进程需要修改内存空间时怎么办呢?因为进程尝试写一个不可写的物理页块,会触发 Store page fault,可以通过检查物理页块的标志位,确定它是一个 COW 页块,那么就拷贝其内容到一个新申请的物理页块上,将这个新的物理页块映射到需要修改内存的进程中,供其使用。这样父子进程就不会相互影响。

当多个进程使用同一物理页块时,可以通过维护一个引用计数数组,来确定释放物理页块时是假释放还是真销毁。

这种方式避免了无意义的拷贝工作,而是将真正有『价值』的拷贝工作延后到需要的时候,避免了浪费。

结合 COW 的 fork() 实现

具体实现可见 GitHub

创建新进程

首先,可以在 kernel/kalloc.c 中维护一个引用计数数组,来确定每个物理页块的引用数,同时也需要引入一个锁来保障原子性。cowcount() 则完成对这个计数数组的操作,当标志位 flag 大于 0 时表示为计数数组 +1,小于 0 时表示 -1,等于 0 时只返回对应地址的引用计数。

kernel/risc.h 中定义了用于表示 COW 页块的 PTE 标志位。在 kernel/memlayout.h 中定义了表示引用计数数组长度的 COWCOUNTSZ,和用于计算物理地址对应数组索引的 COWCOUNT(pa),这里的 pa 指物理页块的首地址。

考虑到 kernel/param.h 中通过 NPROC 来定义 xv6 中进程的最大数量,引用计数数组使用 unsigned char 类型即可,节省了内存空间。

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
// kernel/risc.h
#define PTE_COW (1L << 8)

// kernel/memlayout.h
#define COWCOUNTSZ ((PHYSTOP - KERNBASE) / PGSIZE)
#define COWCOUNT(pa) (((uint64)(pa)-KERNBASE) / PGSIZE)

// kernel/kalloc.c
struct {
struct spinlock lock;
unsigned char count[COWCOUNTSZ];
} cowcounts;

char cowcount(uint64 pa, int flag) {
char count = 0;

acquire(&cowcounts.lock);
if (flag > 0) {
cowcounts.count[COWCOUNT(pa)]++;
} else if (flag < 0) {
cowcounts.count[COWCOUNT(pa)]--;
}
count = cowcounts.count[COWCOUNT(pa)];
release(&cowcounts.lock);

return count;
}

对于未使用的物理页块,其引用计数为 0,当通过 kalloc() 申请一个新的物理页块时,会将其对应的引用计数设置为 1。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// kernel/kalloc.c
void *kalloc(void) {
struct run *r;

acquire(&kmem.lock);
r = kmem.freelist;
if (r) {
kmem.freelist = r->next;
}
release(&kmem.lock);

if (r) {
memset((char *)r, 5, PGSIZE);
cowcount((uint64)r, 1);
}

return (void *)r;
}

追踪 xv6 源代码可以看到,执行 fork() 时会调用 uvmcopy() 来拷贝父进程的内存空间给子进程并映射到其页表上。当使用 COW 机制时,需要舍弃拷贝页面的操作,转而直接在子进程的页表上将虚拟地址映射到父进程的物理页块上,同时为物理页块的引用计数 +1。

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
// kernel/vm.c
int uvmcopy(pagetable_t old, pagetable_t new, uint64 sz) {
pte_t *pte;
uint64 pa, i;
uint flags;

for (i = 0; i < sz; i += PGSIZE) {
if ((pte = walk(old, i, 0)) == 0) {
panic("uvmcopy: pte should exist");
}
if ((*pte & PTE_V) == 0) {
panic("uvmcopy: page not present");
}

pa = PTE2PA(*pte);
if (*pte & PTE_W) {
*pte &= ~PTE_W;
*pte |= PTE_COW;
}
cowcount(pa, 1);
flags = PTE_FLAGS(*pte);
if (mappages(new, i, PGSIZE, (uint64)pa, flags) != 0) {
// 由于之前增加过计数器,映射失败需要释放一个计数器
kfree((void *)pa);
goto err;
}
}
return 0;

err:
uvmunmap(new, 0, i / PGSIZE, 1);
return -1;
}

值得注意的是,Lab 的单元测试中会涉及到对 text segment 的修改。而参考 xv6 的进程内存模型,可以看到 text segment 属于只读,如果对它加上 PTE_COW 标志,则在单元测试中会报错,所以只修改可写页,为其加上 PTE_COW 标志。

xv6-cow-1

需要修改内存

当一个进程需要修改内存时,修改不可写的物理页块会触发 Store page fault,在 kernel/trap.cusertrap() 则可以捕获这个 page fault,并完成 COW 机制的 copy 操作。

首先需要在 usertrap() 中捕获 Store page fault,通过 r_scause() 读取 scause 寄存器,获取状态码,15 表示 Store page fault,其他状态码如图所示。并通过 r_stval() 获取发生 Store page fault 错误的虚拟地址。然后会调用 writecowpage() 来处理 COW 页,如果返回值为 -1 时,将当前进程杀死。

xv6-cow-2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// kernel/trap.c
void usertrap(void) {
// ...
if (r_scause() == 8) {
// system call
} else if (r_scause() == 15) {
// Store page fault
uint64 va = r_stval();

if (va > p->sz) {
printf("Error: The virtual address greater then this process's size\n");
p->killed = 1;
} else if (writecowpage(p->pagetable, va) != 0) {
printf("Error: This page is not a cow-page or xv6 don't have enouth page, so not allow to write\n");
p->killed = 1;
}
}
// ...
}

writecowpage() 中,会根据虚拟地址映射的物理页块,确定其引用计数。如果大于 1,说明有多个进程共享这个物理页块,那么需要拷贝其内容到一个新申请的物理页块上,交由进程修改;如果等于 1,说明只有一个进程使用这个物理页块,那么直接修改其标志位,允许写即可。

通过 uvmunmap() 可以将原来的物理地址取消映射,在 uvmunmap() 中调用 kfree() 减少引用计数;并通过 mappages() 将虚拟地址映射到新申请的物理页块上。

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
// kernel/trap.c
int writecowpage(pagetable_t pagetable, uint64 va) {
va = PGROUNDDOWN(va);
pte_t *pte = walk(pagetable, va, 0);
if (pte == 0) {
panic("COW: fail to get pte");
}

uint flags = PTE_FLAGS(*pte);
if (!(flags & PTE_COW)) {
// this page is not a cow page
printf("COW: This page from %p is not a cow page\n", va);
return -1;
}

uint64 pa = PTE2PA(*pte);
if (cowcount(pa, 0) > 1) {
char *mem = 0;
flags |= PTE_W;
flags &= ~PTE_COW;

if ((mem = kalloc()) == 0) {
printf("COW: fail to kalloc, kill current process\n");
// 如果没有足够的页面则杀死进程
return -1;
}

memmove(mem, (char *)pa, PGSIZE);
uvmunmap(pagetable, va, 1, 1);
if (mappages(pagetable, va, PGSIZE, (uint64)mem, flags) != 0) {
printf("COW: fail to mappages\n");
kfree(mem);
return -1;
}
} else {
*pte |= PTE_W;
*pte &= ~PTE_COW;
}

return 0;
}

copyout() 中,也需要考虑用户内存映射到的物理页块是不是 COW 页块,如果是的话也需要调用 writecowpage() 来实现 copy。

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
// kernel/vm.c
int copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len) {
uint64 n, va0, pa0;

while (len > 0) {
va0 = PGROUNDDOWN(dstva);
pa0 = walkaddr(pagetable, va0);
if (pa0 == 0) {
return -1;
}

if (cowcount(pa0, 0) > 1) {
writecowpage(pagetable, va0);
pa0 = walkaddr(pagetable, va0);
}

n = PGSIZE - (dstva - va0);
if (n > len) {
n = len;
}
memmove((void *)(pa0 + (dstva - va0)), src, n);

len -= n;
src += n;
dstva = va0 + PGSIZE;
}
return 0;
}

销毁

当一个进程结束,或者触发 Store page fault 并取消原有映射时,会调用 kfree() 来『释放』物理页块,是假释放还是真销毁,取决于物理页块的引用计数。当没有其他进程共享物理页块时,调用 kfree() 会彻底销毁这个物理页块并归入freelist;否则只是减少引用计数,做假释放。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// kernel/kalloc.c
void kfree(void *pa) {
struct run *r;

if (((uint64)pa % PGSIZE) != 0 || (char *)pa < end || (uint64)pa >= PHYSTOP) {
panic("kfree");
}

// 首先减少引用计数
// 引用计数为 0 则表示最后一个进程释放了这个 page
if (cowcount((uint64)pa, -1) != 0) {
return;
}

// Fill with junk to catch dangling refs.
memset(pa, 1, PGSIZE);

r = (struct run *)pa;

acquire(&kmem.lock);
r->next = kmem.freelist;
kmem.freelist = r;
release(&kmem.lock);
}

实验后记

实际上 COW Lab 的相关概念很简单,清晰易懂,但是真上手了去实现又是另一种难度,需要注意非常多的细节,做好规划。

但实际上用时颇长,都是下班后的闲暇时间进行,但绝大多数时间都是在摸鱼划水。果然上班跟上学的感觉还是不一样,上了一天班回来只想瘫着,根本没有什么心思继续学习。这样不好,不好。

现在正瘫在出租屋的椅子上码这篇记录,码不动了,有种莫名其妙的心累。

利用 nginx-upload-module 实现文件上传和重命名

作者 Noicdi
2022年3月23日 17:00

近来因为毕设需求,需要有一个简单的文件上传服务,结合 Nginx,最终通过 nginx-upload-module 实现。但是该模块开发者考虑到同时间同名文件上传碰撞,将文件名统一设置为一串经过计算得到的数值,交由后端服务对文件做改动。

本来不是啥大问题,只是网上教程都使用其他语言实现文件重命名,我实在不想因为这么一个简单的需求在毕设中引入其他后端语言,于是决定自写一个模块实现文件重命名功能,源码保存在 nginx-upload-rename-module 中。

毕设内容是基于 Nginx 实现流媒体点播服务器,打算自写一个支持 HLS 协议的模块,实现从 .mp4 到 .m3u8 的转码切分。正好写这个重命名模块,学习 Nginx 模块开发。

此次实验在本地机上进行,在服务器上部署基本一致。

❗ 请注意文末的提示!

思路

nginx-upload-module 的工作流程是将上传文件从 HTTP 报文中剥除,组合成文件,计算特定数值后保存到指定目录;其他文件信息则经过整合写入 HTTP 报文的请求体,交由后端处理。

我本打算直接调用它定义的变量,获取诸如文件名、文件类型等信息,直接完成文件重命名,但是可能是它变量定义时对标志位的设置,无法获取索引也没有记录入散列表,无法获得其值,就此作罢。

输出其 HTTP 报文的过程中,偶然明白了 nginx-upload-module 的 HTTP 报文传递到后端时的具体内容,于是决定对 HTTP 报文做处理,获取其特定字段,保存后用以处理文件。

编译

首先下载 Nginx 源码和两个模块:

1
2
3
wget https://nginx.org/download/nginx-1.20.2.tar.gz
git clone https://github.com/fdintino/nginx-upload-module.git
git clone https://github.com/xQmQ/nginx-upload-rename-module.git

解压缩 Nginx 源码后,有以下三个目录:

1
2
3
4
.
├── nginx-1.20.2
├── nginx-upload-module
└── nginx-upload-rename-module

进入nginx-1.20.2,准备编译。这里我直接将安装路径放在了当前目录:

自行安装 Nginx 源码编译时需要的依赖

1
./configure --prefix=$(pwd)/nginx --add-module=../nginx-upload-rename-module --add-module=../nginx-upload-module

经过检查后得到以下信息:

image-20220323181439714

然后执行编译:

1
make

image-20220323181532032

编译成功后安装:

1
make install

这样就得到了配置文件和二进制文件:

image-20220323181624796

配置

首先将nginx-upload-rename-module/UploadSuccess目录复制入nginx/html中。这个目录里有上传成功后返回给前端的页面。

image-20220323181904689

然后修改 Nginx 的配置文件conf/nginx.conf,在http{}中设置以下信息,监听的端口自定,我这里就直接监听 1080 端口。

注意配置upload_store时,指定的目录要存在,且有相应的权限,目录可以自定;client_max_body_size决定上传文件的大小;其他配置项可以看看 ngin-upload-module 的 README

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
server {
listen 80;

client_max_body_size 100m;

# Upload form should be submitted to this location
location /upload {
# Pass altered request body to this location
upload_pass /uploadSuccess;

# Store files to this directory
# The directory is hashed, subdirectories 0 1 2 3 4 5 6 7 8 9 should exist
upload_store /tmp;

# Allow uploaded files to be read only by user
upload_store_access user:rw group:rw all:rw;

# Set specified fields in request body
upload_set_form_field $upload_field_name.name "$upload_file_name";
upload_set_form_field $upload_field_name.content_type "$upload_content_type";
upload_set_form_field $upload_field_name.path "$upload_tmp_path";

# Inform backend about hash and size of a file
upload_aggregate_form_field $upload_field_name.md5 "$upload_file_md5";
upload_aggregate_form_field $upload_field_name.size "$upload_file_size";

upload_pass_form_field "^submit$|^description$";

upload_cleanup 400 404 499 500-505;
}

# Pass altered request body to a backend
location /uploadSuccess {
upload_files_rename on;
}
}

启动

设置好后,进入sbin/,准备启动:

2022-03-23_18-27-08

我没有写前端上传的页面,打算利用 Apifox 直接调用接口。

POST 请求,并插入待上传的文件,访问localhost:1080/upload

我这里在本地机做实验。

image-20220323183258019

可以看到返回了成功界面。

实际上进行上传并调用upload_pass后即会返回此页面,不代表文件重命名成功,但是一般情况下都是成功的。

也可以看到指定的目录下有了对应的文件:

image-20220323183512247

而如果没有启动 nginx-upload-rename-module,一般来说文件名如下,一串数字所示:

image-20220323183627041

提示

因为个人能力有限,模块可能会存在 bug,已经测试过多个不同名文件同时上传,没有什么问题。

但是正如 nginx-upload-module 开发者所说,存在文件碰撞的可能,所以他们决定让用户在后端程序(如 PHP)中移动和重命名文件。我没有条件测试同一上传时间的同名文件碰撞。

但是对于同文件名但不同内容的文件 A 和文件 B,先上传文件 A,再次上传文件 B 后,会删除 文件 A 保留文件 B,即没有设置同名文件碰撞处理,请知悉。

其次,由于模块开发过程中挂载方式的选择,upload_pass指定的location /uploadSuccess可能无法启动其他模块的功能,只能启动本模块(暂未测试)。

所以开发的模块只适用于简单的文件上传服务,请小心使用。

在 WSL2 的 Arch Linux 下编译并替换内核

作者 Noicdi
2022年1月29日 15:05

闲来无事,想把 WSL2 的 kernel 升级一下。GitHub 中微软官方发布的 WSL2 kernel 最新版本为5.10.74.3,但是 Microsoft Update Catalog 中一直没有相应版本的安装包(截止本文发布)

于是打算自己动手编译一下

编译环境

WSL2 下的 Arch Linux

image-20220129195006232

编译内核

整体编译思路就是利用微软的编译配置文件来编译 Linux kernel,并在相应的 Windows 目录下替换现有的内核

这里放一下 GitHub 中的 WSL2-Linux-Kernel

下载源码

在 GitHub 下载相应版本的 releases

1
wget https://github.com/microsoft/WSL2-Linux-Kernel/archive/refs/tags/linux-msft-wsl-5.10.74.3.tar.gz

下载完成后解压缩并进入目录

1
2
tar zxvf linux-msft-wsl-5.10.74.3.tar.gz
cd WSL2-Linux-Kernel-linux-msft-wsl-5.10.74.3/

为确保内核树绝对干净,进入内核目录并执行make mrproper命令

1
make clean && make mrproper

安装编译工具

微软也在项目里告知了步骤,其中也包括编译工具

image-20220129183105502

这个不能照葫芦画瓢直接复制下来,需要在 ArchWiki 中找一下 Arch Linux 中的包

build-essentialflexbison都集成在 Arch Linux 的base-devel里了,其他两个也有相对应的包

1
sudo pacman -S base-devel openssl libelf pahole xmlto kmod inetutils bc

关于pahole

在多次编译时,最终都会报错

1
2
3
BTF: .tmp_vmlinux.btf: pahole (pahole) is not available
Failed to generate BTF for vmlinux
Try to disable CONFIG_DEBUG_INFO_BTF

一种方法是在Microsoft/config-wsl中对CONFIG_DEBUG_INFO_BTF设置关闭

✨ 另一种方法是下载pahole,此方法取自 Stack Overflow,其他发行版遇到此问题也可以看一下


关于其余四个包:

ArchWiki 推荐


2022-02-08更新:

在最新推出的5.10.93.2版本中,官方修复了关于以上提到的BTF报错,针对 Ubuntu 给出依赖项dwarves

image-20220208151648920

image-20220208151617183


修改编译配置文件

打开Microsoft/config-wsl文件,把内核号修改了一下,改成自己的名字

image-20220129200205133

开始编译

1
make KCONFIG_CONFIG=Microsoft/config-wsl -j

编译开始时会出现编译选项,全部按照默认就可以了

image-20220129200241690

然后就可以等着编译完成了


可以看到编译完成的内核所在的路径为arch/x86/boot/bzImage

image-20220129201037392

image-20220129201122975

这就是编译好的 WSL2 的内核了

替换内核

WSL2 使用的内核是放在 Windows 目录下的C:\WINDOWS\System32\lxss\tools

只需要把编译好的bzImage复制到此路径下,并更名为kernel即可

image-20220129191238317

可以使用文件管理器复制内核

1
explorer.exe .

关闭所有 WSL,并将bzImage复制到C:\WINDOWS\System32\lxss\tools下并更名为kernel

替换内核前需要将所有 WSL 关闭

image-20220129201443082


启动 Arch Linux,并截图

image-20220129201701803

🌈 可以看到内核已经替换为5.10.74.3版本了

Qver - 用于练手的服务器程序

作者 Noicdi
2021年9月14日 17:48

利用 C++ 11 编写一个简单的 Linux HTTP 服务器,主要用于静态页面的部署

  • 使用非阻塞 socket + epoll + 半同步/半反应堆模式 + 线程池来处理客户连接
  • 通过有限状态机处理 HTTP 请求报文(GET)

项目地址:xQmQ/Qver: C++ 11 实现的简单 HTTP server

函数

httpEvent(int fd):推入到线程池任务队列的处理函数,通过fd创建HttpEvent类型对象,调用相应的处理函数,完成对 HTTP 请求的处理

HttpEvent:HTTP 请求处理类

ThreadPool:线程池实现,通过ThreadPool::submitWork()推送任务并唤醒工作线程处理

Socket:注册并绑定 socket 连接到服务器特定端口,接受所有客户连接

Epollerepoll的封装实现,用于接收socket接受的连接并监听其是否有读写请求;通过定时器剔除非活动连接

Timer:定时器,用于处理非活动连接

处理流程

Qver-FlowChart

主线程

  1. 主线程建立Socket对象,建立ThreadPool对象
  2. 非阻塞的Socket对象源源不断接收连接并注册到Epoller监听,并插入Timer定时器
  3. Epoller监听到就绪读请求时,获取对应fd并通过httpEvent(int fd)注册到线程池任务队列,由线程池唤醒工作线程并处理;同时从Timer中剔除链接
  4. 通过Timer定期处理非活动连接,从Epoller中剔除并关闭
  5. 监听外部信号,关闭线程池,结束进程

工作线程

  1. 线程池唤醒工作线程并从任务队列中获取httpEvent(int fd)
  2. 工作线程建立HttpEvent对象,绑定fd
  3. HttpEvent获取fd中的数据到读缓冲区,通过有限状态机解析 HTTP 请求,并准备相应资源写入写缓冲区,并发送给客户

两种事件处理模式

半同步/半反应堆模式

流程:

  1. 建立线程池
  2. 主线程监听 socket,当接收到一个客户端 connect 时,accept 并将其注册到 epoll 内核事件表
  3. 通过epoll_wait()获得客户端的读写请求,将读写请求插入任务队列
  4. 工作线程通过申请互斥锁,获得读写请求,并处理,返回结果到客户端

缺点:

  1. 主线程向请求队列添加事件或工作线程从请求队列取出事件,即生产者-消费者问题,需要通过互斥锁完成同步。加锁和解锁浪费 CPU
  2. 当请求队列中的读写事件过多时,工作线程较少,无法及时处理,会减慢客户端响应时间

高效半同步/半异步模式

流程:

  1. 建立线程池,初始化线程,各线程新建 epoll 内核事件表,新建私有请求队列
  2. 主线程监听 socket,当接收到一个客户端 connect 时,accept 连接
  3. 主线程轮询线程池中所有工作线程的状态,挑选负载最小的工作线程并传递 fd 到工作线程的私有请求队列中

    详情:《Linux 多线程服务端编程》9.1.2 小节

  4. 工作线程轮询请求队列,如果有 fd 则注册到自己的 epoll 内核事件表中,通过epoll_wait()获得客户端的读写请求
  5. 工作线程处理读写请求,返回结果到客户端;查看请求队列是否还有事件需要处理

遇到的问题

  1. 任务队列的empty()size()都设置为const,与函数中上锁的操作发生了冲突
  2. ThreadPool中定义了一个返回类型推导的任务提交函数submitWork(),类中的函数声明与定义分离在Threadpool.hThreadPool.cpp中,但是模板函数的声明和定义不可以分离。我的解决办法是将submitWork()定义在Threadpool.h
  3. 第一版的ThreadPool可能存在线程不安全的情况。在构造函数中,创建工作线程并向工作线程暴露了ThreadPoolthis指针,如果ThreadPool初始化到一半,其他线程会访问这个半成品;析构函数中也存在问题,在代码中存在唤醒工作线程执行余下的任务这样的操作,这些工作线程会调用ThreadPool::conditional_mutex_,如果析构函数销毁了互斥锁,对于未结束的工作线程来说会破坏互斥环境

    《Linux 多线程服务端编程》1.2,1.3

  4. 线程池设计思路出现问题。线程池的关闭应当由shutdown()来定义,不应该放在析构函数中,何时执行shutdown()?考虑到这是一个服务器,不用关闭,需要关闭的时候应当由外部信号来决定是否调用shutdown()
  5. epoll 检测事件时需要关注,socket 是否被客户端主动关闭,通过addEvent()时对检测的事件调整为EPOLLET | EPOLLIN | EPOLLRDHUP;且处理EPOLLIN之前需要先close()对端关闭的 socket
  6. epoll 对于EPOLLRDHUPEPOLLIN | EPOLLOUT的处理需要注意:应当优先处理EPOLLRDHUP,然后再处理其他操作
  7. epoll 对于EPOLLOUT的触发问题。高位表示缓冲区可写,低位表示不可写。在水平触发LT中,由低位转高位和处于高位这两种情况都会触发EPOLLOUT;在边缘触发ET中,只有低位转高位才会触发EPOLLOUT。一般来说,在连接建立时,缓冲区是可写的,这时就会触发,所以处于ET时,后续操作中会存在无法触发EPOLLOUT导致无法执行写操作,可以通过epoll_ctl()EPOLL_CTL_MOD重新设置一次EPOLLOUT
  8. 当客户端主动关闭一个 socket(操作一),并且紧接着申请一个新的 socket 连接时(操作二)(两个操作中间没有其他客户端的操作),会导致操作二继承操作一的文件描述符。当操作一发生后,文件描述符并没有从定时器链表中去除;操作二继承的文件描述符会继续以操作一的定时来确定是否是非活动连接,这里需要注意处理。
  9. 对于检测信号中的结构struct sigaction.sa_handler,其记录的是信号触发时的处理函数,以回调函数的形式表示。且此处理函数只能有一个参数,为信号值。在原本的设计中,计划将这些涉及到信号处理的函数封装成类,但是如果封装成类,处理函数作为成员函数时,无法作为回调函数绑定在struct sigaction.sa_handler(此时有两个参数,一个是隐藏参数this

参考

  • 《Linux 高性能服务器编程》
  • 《Linux 多线程服务端编程》
  • 《Linux / Unix 系统编程手册》
  • 《HTTP 权威指南》
  • 《C++ 服务器开发精髓》

利用自定义 WSL 构建 CLion 编译工具链

作者 Noicdi
2021年9月2日 18:55

此博客用于记录 CLion 搭建编译工具链的过程。WSL 的 arch Linux 通过 LxRunOffline 自定义安装

以下是相关信息:

  • CLion:2021.2.1
  • WSL2:Arch Linux on Windows 10

一般通过 Microsoft store 安装的 WSL,CLion 是可以直接发现的,但是自定义安装的发行版通常无法发现,这时候可以通过更改 CLion 的配置文件解决这个问题

WSL 的配置信息一般位于%HOMEPATH%\AppData\Roaming\JetBrains\XXXXX\options\wsl.distributions.xml中,XXXXX 对应于所使用的软件版本

比如我的 CLion 版本为 2.21.2.1 ,则对应路径为%HOMEPATH%\AppData\Roaming\JetBrains\CLion2021.2\options\wsl.distributions.xml

打开这个文件后,可以看到定义了很多已经在 Microsoft store 上发行的版本,接下来可以配置我们的自定义版本

比如我的 arch Linux 的路径为C:\WSL\Arch\Arch,在文件中进行配置

1
2
3
4
5
6
<descriptor>
<id>ARCH</id>
<microsoft-id>Arch</microsoft-id>
<executable-path>C:\\WSL\\Arch\\Arch\\</executable-path>
<presentable-name>Arch Linux</presentable-name>
</descriptor>

注意:配置文件中的路径应当用\\

image-20210902191030908

【OS】内存管理

作者 Noicdi
2021年8月24日 21:41

内存管理

三种装入方式

逻辑地址:某一个进程中存放数据的地址,一般开头为 0

绝对地址:数据在内存中的地址,一般是起始地址 + 逻辑地址

程序员写好的程序,通过编译、链接和装入,实现最终的运行

编译:由编译程序将源代码编译成若干个目标模块(高级语言翻译为机器语言)

链接:由链接程序将目标模块和所需库函数链接在一起,形成一个完整的装入模块

装入:由装入程序将装入模块装入内存运行

在不适用存储器抽象的情况下,通过保存当前程序到磁盘,读入下一个程序到内存,来实现运行多个程序。在此时内存中只有一个程序,不会发生冲突

绝对装入:编译时,知道程序放到内存哪个地方,编译程序产生绝对地址的目标代码,将程序装入绝对地址,只适用于单道程序环境

静态重定位(可重定位装入):编译、链接后的装入模块的地址都是从 0 开始,使用逻辑地址,根据内存情况装入到适当位置,在装入时对地址进行重定位,将逻辑地址转变为物理地址(在装入时一次完成)。在装入作业时,需要一次分配作业要求的全部内存空间,作业在运行期间不能移动,不能再申请内存空间

动态重定位(动态运行时装入):编译、链接后的装入模块的地址都是从 0 开始,使用逻辑地址,根据内存情况装入到适当位置,调用一个基址寄存器记录起始地址,在程序真正运行时进行地址转换,运行程序在内存中发生移动


内存的离散分配管理

分页式存储管理

基本思想:内存分为一个个大小相等的小分区,再按照分区大小把进程拆分成一个个小部分

物理块:内存中一个个大小相等的小分区,每个物理块有一个物理块号,物理块号从 0 开始

页:用户进程中按照物理块大小分割的一个个小部分,每个页有一个页号,页号从 0 开始

页大小一般为 2 的整数幂,例如 ;对应的,物理块大小也是 4KB

页大小具体看操作系统设置,这里的 4KB 用作举例

进程的最后一个页面可能没有物理块那么大,放入物理块后会产生内部碎片

操作系统以物理块为单位为每个进程分配内存空间,进程的每个页被放入一个物理块中,即进程的页号与内存的物理块号相对应,页面不一定被连续存放在内存中,可以放在不相邻的物理块中


逻辑地址到物理地址的转换

  1. 算出页号

    地址与页号

    以 32 位操作系统为例,逻辑地址长度为 32 位,则一个进程最大为 。第 0-11 位表示页内偏移量,也可以得出一个页的大小为 个内存单元,第 12-32 位用作表示页号,即一个进程中最多有 个页

    一个内存单元大小为 1B

    通过逻辑地址的第 12~32 位,可以计算出此地址对应的页号

  2. 计算页号对应的内存中的起始地址

    操作系统会为每个进程维护一张页表,页表的每一个页表项记录的是页号到物理块号的转换

    页号到块号

    一个页表项的大小需要根据页的大小来决定。一个页的大小为 4K 个内存单元,意味着表示起来用 20 位,也就是接近 3 字节

    页表中实际上不需要写出页号,页号是顺序记录下来的,可以通过页表始址 + 对应页号 M * 页表项大小,找到页表项在页表中的位置,从而获得物理块号

    M 号物理块在内存中的实际地址就是 M * 物理块大小

  3. 算出页内地址(页内偏移量)

    根据逻辑地址的 0~11 位可以算出在物理块中的地址,也就是页内偏移量

    毕竟得到的物理块地址,实际上是一个物理块的起始地址

  4. 物理地址=物理块起始地址 + 页内偏移量


页表寄存器与快表

基本地址变换机构:用于实现逻辑地址到物理地址转换的一组硬件机构

页表寄存器(PTR):记录页表的内存起始地址页表长度

进程未执行时,页表的始址和长度记录在 PCB 中;进程被调度后,始址和长度存放到页表寄存器中

快表,又称为联想寄存器(TLB):是一种访问速度比内存快很多的高速缓冲存储器,用来存放当前访问的若干页表项

快表

  1. 逻辑地址计算页号和页内偏移量
  2. 由页表寄存器获得页表长度
  3. 页号 <= 页表长度,则未越界
  4. 查看快表,是否命中,命中则直接获得物理块号
  5. 快表未命中则由页表寄存器获得页表始址,找到内存中的页表,计算页号对应的物理块号(页表始址 + 页号 * 页表项长度),并存入快表
  6. 根据物理块号 * 物理块大小 + 页内偏移量计算物理地址,找到内存中的目标页面

查看快表不需要访问内存,查看页表需要访问内存

快表命中,全程访存一次;快表未命中,全程访存两次


二级页表

对于现代计算机,大多数是 ​​​​ 内存,页表非常大。设页面大小为 4KB,一级页表的页表项有 ​ 至 ​​​​​ 个,再乘以页表项大小,就意味着每个进程需要极大的连续的内存空间用来存储页表

实际上一个进程不可能需要用到这么大的内存,划分多级页表后,外层页表记录内层页表,可以做到:

  1. 页表可以离散存放在不同的物理块中
  2. 只需要将用到的页表调入到内存,用不到的放在磁盘中
  3. 进程不可能用到这么大的内存,外层页表可以忽略没有映射的内存页表

多级页表

  1. 转换逻辑地址得到一级页号、二级页号和页内偏移量
  2. 通过页表寄存器找到页目录表始址,根据一级页号得到存放二级页表的物理块号 X
  3. X * 物理块大小,找到物理内存中存储二级页表的地址
  4. 根据二级页号在二级页表中找到想要访问的物理块号 Y
  5. Y * 物理块大小,在物理内存中找到想要访问的物理块,根据页内偏移量找到物理地址

一些注意点:

  1. 采用多级页表机制时,各级页表的大小不能超过一个页
  2. 与一级页表和快表一样,访存次数看是否命中以及访问几次页表

分段式存储管理

进程中的地址空间按照程序自身的逻辑关系划分为若干个段,每个段一个段名,每段从 0 开始

以段为单位分配内存空间,每个段在内存中占据连续空间,但各段之间可以不相邻

分段式

  1. 由逻辑地址计算得到段号和段内偏移量
  2. 由段表寄存器获得段表长度
  3. 段号 <= 段表长度,则未越界
  4. 由段表寄存器获得段表始址,找到内存中的段表,计算段号对应的段表项(段表始址 + 段号 * 段表项长度)
  5. 段内偏移量 <= 段长,则未越界
  6. 根据段基址和段内偏移量计算物理地址,找到内存中的目标段

分页式与分段式的对比

页是信息的物理单位:分页的主要目的是为了实现离散分配,提高内存利用率。分页仅仅是系统管
理上的需要,完全是系统行为,对用户是不可见的

段是信息的逻辑单位:分段的主要目的是更好地满足用户需求。一个段通常包含着一组属于一个逻
辑模块的信息。分段对用户是可见的,用户编程时需要显式地给出段名

页的大小固定且由系统决定;段的长度却不固定,决定于用户编写的程序

分页的用户进程地址空间是一维的,程序员只需给出一个记忆符即可表示一个地址

分段的用户进程地址空间是二维的,程序员在标识一个地址时,既要给出段名,也要给出段内地址

优点缺点
分页式管理内存空间利用率高,不会产生外部碎片,只会有少量内部碎片不方便按照逻辑模块实现信息的共享和保护
分段式管理方便按照逻辑模块实现信息的共享和保护如果段长过大,为其分配很大的连续空间会很不方便,且会产生外部碎片

段页式存储管理

段页式

  1. 由逻辑地址计算得到段号、页号和页内偏移量
  2. 由段表寄存器获得段表长度
  3. 段号 <= 段表长度,则未越界
  4. 由段表寄存器获得段表始址,找到内存中的段表,计算段号对应的段表项(段表始址 + 段号 * 段表项长度)
  5. 页号 <= 页表长度,则未越界
  6. 根据段表项的页表块号(也是一个物理块),找到独属于此段的页表(块号 * 物理块大小
  7. 根据页号得到物理块号 * 物理块大小 + 页内偏移量计算物理地址,找到内存中的目标页面

快表命中则访存一次,未命中则访存三次


内存紧张时如何解决?

当内存空间紧张时,有两种方式解决。一种是交换技术;一种是虚拟内存。这两种一般同时使用,以提高内存利用效率


交换技术

当内存空间紧张时,通过将内存中某些进程(阻塞态、低优先级等)暂时交换到外存中,但是保留 PCB 在内存中;将处于就绪态的进程从外存调入内存,分配 CPU 并运行

交换技术也就是处理机调度中的中级调度

  1. 保存在外存什么位置?

    外存通常会划分为对换区和文件区。文件区是离散分配的(追求空间利用率),对换区是连续分配的(追求文件查找的 I/O 效率)。通常会将被交换的进程保存在对换区

  2. 什么时候交换?

    当运行的进程频繁缺页,需要去外存调取时,说明内存吃紧。这时候可以发生交换

  3. 交换哪些进程?

    优先换出阻塞态进程、低优先级进程。PCB 保存在内存中,不会换出


虚拟内存技术

离散式存储管理存在以下特性:

  1. 一次性:作业必须一次性全部装入内存后才能开始运行。这会造成两个问题:
    • 作业很大时,不能全部装入内存,导致大作业无法运行
    • 当大量作业要求运行时,由于内存无法容纳所有作业,因此只有少量作业能运行,导致多道程序并发度下降
  2. 驻留性:一旦作业被装入内存,就会一直驻留在内存中,直至作业运行结束。事实上,在一个时间段内,只需要访问作业的一小部分数据即可正常运行,这就导致了内存中会驻留大量的、暂时用不到的数据,浪费了宝贵的内存资源

虚拟内存技术在内存的离散式存储管理的基础上,做出改进。不会一次将作业全部装入内存,只会装入很快会用到的部分,其他部分留在外存;程序执行过程中,访问的信息不在内存中时,由操作系统将所需信息从外存调入内存,然后继续执行程序;如果内存空间不够用,会调出暂时不用的信息到外存

虚拟内存技术的主要特征:

  1. 多次性:无需在作业运行时一次性全部装入内存,而是允许被分成多次调入内存
  2. 对换性:在作业运行时无需一直驻留内存,而是允许作业在运行过程中,被换入或换出
  3. 虚拟性:从逻辑上扩充了内存的大小,使用户看到的内存容量远大于实际容量

请求分页管理方式

  1. 由于暂时用不到的信息会留在外存,所以页表需要记录当前准备使用的页面是否在内存中,需要有标志位来识别
  2. 由于需要将暂时不用的信息换出内存,操作系统需要知道通过哪些指标来换出哪些页面;有些页面没有被修改过,则不用写回外存,可以直接覆盖使用;有些页面修改过,需要写回

页表对比

状态位:是否已调入内存

访问字段:记录最近被访问过几次;或最近被访问的时间。供页面置换算法参考

修改位:调入内存后是否被修改

外存地址:页面在外存中的存放位置


缺页中断机制

当需要访问的页面不在内存时,产生一个缺页中断,然后由操作系统的缺页中断处理程序处理中断

此时缺页的进程阻塞,放入阻塞队列,调页完成后再将进程唤醒,放回就绪队列

  • 如果内存中有空闲块,为进程分配一个空闲块,将所缺页面装入该块,并修改页表中相应的页表项
  • 如果内存中没有空闲块,由页面置换算法选择一个页面淘汰,如果被淘汰的页面在内存中修改过,则需要写回外存,未修改的页面不需要写回外存

地址变换机构

请求分页管理

image-20201203195937208

补充:

  1. 只有写指令才需要修改修改位。并且,一般来说只需修改快表中的数据,只有要将快表项删除时才需要写回内存中的慢表。这样可以减少访存次数
  2. 和普通的中断处理一样,缺页中断处理依然需要保留 CPU 现场
  3. 需要用某种页面置换算法来决定一个换出页面
  4. 换入/换出页面都需要启动慢速的 I/O 操作,可见,如果换入/换出太频繁,会有很大的开销
  5. 页面调入内存后,需要修改慢表,同时也需要将表项复制到快表中;所以在快表中出现的页一定在内存中

页面置换算法

缺页时未必发生页面置换,如果还有可用的空闲内存块,就不用进行页面置换

缺页率 = 缺页次数 / 页面访问次数

最佳置换算法(OPT)

每次选择淘汰的页面将是以后永不使用,或者在最长的时间内不再被访问的页面,这样可以保证最低的缺页率

最佳置换算法可以保证最低的缺页率,但实际上,只有在进程执行的过程中才能知道接下来会访问到的是哪个页面。操作系统无法提前预判页面访问序列。因此,最佳置换算法是无法实现的

先进先出置换算法(FIFO)

每次选择淘汰的页面是最早进入内存的页面

实现方法:把调入内存的页面根据调入的先后顺序排成一个队列,需要换出页面时选择对头页面即可,队列的最大长度取决于系统为进程分配了多少个内存块

最近最久未使用置换算法(LRU)

每次淘汰的页面是最近最久未使用的页面,需要专门的硬件支持,虽然算法性能好,但是实现困难,开销大

实现方法:赋予每个页面对应的页表项中,用访间字段记录该页面自上次被访问以来所经历的时间 t。当需要淘汰一个页面时,选择现有页面中 t 值最大的,即最近最久未使用的页面。

时钟置换算法(CLOCK)

简单的 CLOCK 算法实现方法:为每个页面设置一个访问位,再将内存中的页面都通过链接指针链接成一个循环队列。当某页被访问时,其访问位置为 1;当需要淘汰一个页面时,只需检查页的访问位。如果是 0,就选择该页换出;如果是 1,则将它置为 0,暂不换出,继续检查下一个页面,若第一轮扫描中所有页面都是 1,则将这些页面的访问位依次置为 0 后,再进行第二轮扫描(第二轮扫描中一定会有访问位为 0 的页面,因此简单的 CLOCK 算法选择一个淘汰页面最多会经过两轮扫描)

时钟算法

改进型的时钟置换算法

简单的时钟置换算法仅考虑到一个页面最近是否被访问过。事实上,如果被淘汰的页面没有被修改过,就不需要执行 I/O 操作写回外存。只有被淘汰的页面被修改过时,才需要写回外存
因此,除了考虑一个页面最近有没有被访问过之外,操作系统还应考虑页面有没有被修改过。在其他条件都相同时,应优先淘汰没有修改过的页面,避免 I/O 操作。这就是改进型的时钟置换算法的思想

修改位为 0,表示页面没有被修改过;修改位为 1, 表示页面被修改过

为方便讨论,用(访问位,修改位)的形式表示各页面状态。如(1, 1)表示一个页面近期被访问过,且被修改过

算法规则:将所有可能被置换的页面排成一个循环队列

  1. 从当前位置开始扫描到第一个(0,0)的帧用于替换。本轮扫描不修改任何标志位

    第一优先级:最近没访问且没修改的页面

  2. 若第一轮扫描失败,则重新扫描,查找第一个(0,1)的帧用于替换。本轮将所有扫描过的帧访问位设为 0

    第二优先级:最近没访问但修改过的页面

  3. 若第二轮扫描失败,则重新扫描,查找第一个(0,0) 的帧用于替换。本轮扫描不修改任何标志位

    第三优先级:最近访问过但没修改的页面

  4. 若第三轮扫描失败,则重新扫描,查找第一个(0,1)的帧用于替换

    第四优先级:最近访问过且没修改过的页面

由于第二轮已将所有帧的访问位设为 0,因此经过第三轮、第四轮扫描一定会有一个帧被选中,因此改进型 CLOCK 置换算法选择一个淘汰页面最多会进行四轮扫描


页面分配策略

驻留集

指请求分页存储管理中给进程分配的物理块的集合,在采用了虚拟存储技术的系统中,驻留集大小一般小于进程的总大小

例子:考虑一个极端情况,若某进程共有 100 个页面,则该进程的驻留集大小为 100 时进程可以全部放入内存,运行期间不可能再发生缺页。若驻留集大小为 1,则进程运行期间必定会极频繁地缺页

若驻留集太小,会导致缺页频繁,系统要花大量的时间来处理缺页,实际用于进程推进的时间很少;驻留集太大,又会导致多道程序并发度下降资源利用率降低。所以应该选择一个合适的驻留集大小

页面分配、置换策略

固定分配:操作系统为每个进程分配一组固定数目的物理块,在进程运行期间不再改变。即,驻留集大小不变

可变分配:先为每个进程分配一定数目的物理块,在进程运行期间,可根据情况做适当的增加或减少。即,驻留集大小可变

局部置换:发生缺页时只能选进程自己的物理块进行置换

全局置换:可以将操作系统保留的空闲物理块分配给缺页进程,也可以将别的进程持有的物理块置换到外存,再分配给缺页进程

固定分配局部置换:系统为每个进程分配一定数量的物理块,在整个运行期间都不改变。若进程在运行中发生缺页,则只能从该进程在内存中的页面中选出一页换出,然后再调入需要的页面。

这种策略的缺点是:很难在刚开始就确定应为每个进程分配多少个物理块才算合理。( 采用这种策略的系统可以根据进程大小、优先级、或是根据程序员给出的参数来确定为一个进程分配的内存块数)

可变分配全局置换:刚开始会为每个进程分配一定数量的物理块。操作系统会保持一个空闲物理块队列。当某进程发生缺页时,从空闲物理块中取出一块分配给该进程;若已无空闲物理块,则可选择一个未锁定的页面换出外存,再将该物理块分配给缺页的进程。

采用这种策略时,只要某进程发生缺页,都将获得新的物理块,仅当空闲物理块用完时,系统才选择一个未锁定的页面调出。被选择调出的页可能是系统中任何一个进程中的页,因此这个被选中的进程拥有的物理块会减少,缺页率会增加

可变分配局部置换:刚开始会为每个进程分配一定数量的物理块。当某进程发生缺页时,只允许从该进程自己的物理块中选出一个进行换出外存

如果进程在运行中频繁地缺页,系统会为该进程多分配几个物理块,直至该进程缺页率趋势适当程度;反之,如果进程在运行中缺页率特别低,则可适当减少分配给该进程的物理块

可变分配 全局 置换:只要缺页就给分配新物理块
可变分配 局部 置换:要根据发生缺页的频率来动态地增加或减少进程的物理块


一些问题

何时调入页面?

  1. 预调页策略:根据局部性原理,一次调入若干个相邻的页面可能比一次调入一个页面更高效。但如果提前调入的页面中大多数都没被访问过,则又是低效的。因此可以预测不久之后可能访问到的页面,将它们预先调入内存,但目前预测成功率只有 50% 左右。故这种策略主要用于进程的首次调入,由程序员指出应该先调入哪些部分

    即运行前调入

  2. 请求调页策略:进程在运行期间发现缺页时才将所缺页面调入内存。由这种策略调入的页面一定会被访问到,但由于每次只能调入一页,而每次调页都要磁盘 I/O 操作,因此 I/O 开销较大

    即运行时调入

何处调入页面?

  1. 系统拥有足够的对换区空间:页面的调入、调出都是在内存与对换区之间进行,这样可以保证页面的调入、调出速度很快(因为对换区是连续分配方式)。在进程运行前,需将进程相关的数据从文件区复制到对换区
  2. 系统缺少足够的对换区空间:凡是不会被修改的数据都直接从文件区调入,由于这些页面不会被修改,因此换出时不必写回磁盘,下次需要时再从文件区调入即可。对于可能被修改的部分,换出时需写回磁盘对换区,下次需要时再从对换区调入
  3. UNIX 方式:运行之前进程有关的数据全部放在文件区,故未使用过的页面,都可从文件区调入。若被使用过的页面需要换出,则写回对换区,下次需要时从对换区调入

抖动(颠簸)现象

  1. 刚刚换出的页面马上又要换入内存,刚刚换入的页面马上又要换出外存,这种频繁的页面调度行为称为抖动,或颠簸。产生抖动的主要原因是进程频繁访问的页面数目高于可用的物理块数(分配给进程的物理块不够)
  2. 为进程分配的物理块太少,会使进程发生抖动现象。为进程分配的物理块太多,又会降低系统整体的并发度,降低某些资源的利用率

多线程编程

作者 Noicdi
2021年7月25日 08:00

线程使用

线程模型

详情见【OS】进程与线程 | xStack 线程模型相关内容


Linux 线程库

Linux 上默认是 NPTL(Native POSIX Thread Library),查询相关信息

1
2
3
4
5
6
~ » neofetch
OS: Arch Linux on Windows 10 x86_64
Kernel: Linux 5.10.43.3-microsoft-standard-WSL2

~ » getconf GNU_LIBPTHREAD_VERSION
NPTL 2.33

创建与结束

1
2
3
4
5
6
#include <pthread.h>
int pthread_create(pthread_t* thread, const pthread_attr_t* attr, void* (*start_routine)(void*),
void* arg);

#include <bits/pthreadtypes.h>
typedef unsigned long int pthread_t;

thread:新线程的标识符

attr:设置线程属性,NULL表示默认属性

start_routine:线程将要执行的函数

art:线程执行函数的参数列表

成功时返回 0,失败时返回错误码

通过输出以下信息可以查询 Linux 中所有用户能创建的线程总数,当前机器最多创建 101244 个线程

1
2
3
4
5
6
~ » neofetch
OS: Arch Linux on Windows 10 x86_64
Kernel: Linux 5.10.43.3-microsoft-standard-WSL2

~ » cat /proc/sys/kernel/threads-max
101244

线程被创建好后,内核调度内核线程来执行start_routine函数指针所指向的工作函数。完成工作后调用退出函数

1
2
#include <pthread.h>
void pthread_exit(void* retval);

retval:通过此参数向线程回收者返回退出信息

1
2
#include <pthread.h>
int pthread_join(pthread_t thread, void** retval);

thread:线程标识符

retval:目标线程返回的退出信息

执行pthread_join()会一直阻塞,直到被回收的线程结束,成功时返回 0,失败返回错误码

1
2
#include <pthread.h>
int pthread_cancel(pthread_t thread);

thread:线程标识符

此函数异常终止一个线程,成功时返回 0,失败返回错误码。被终止的目标线程可以通过以下函数自主决定是否可以被取消以及取消方式

1
2
3
#include <pthread.h>
int pthread_setcancelstate(int state, int* oldstate);
int pthread_setcanceltype(int type, int* oldtype);

是否可以取消:state

PTHREAD_CANCEL_ENABLE:可以取消,创建线程时的默认状态

PTHREAD_CANCEL_DISABLE:不可取消

线程原来的取消状态:oldstate

取消方式:type

PTHREAD_CANCEL_ASYNCHRONOUS:线程可以随时取消,接收取消请求时立即行动

PTHREAD_CANCEL_DEFERRED:可以推迟取消,直到调用取消点函数


例:

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
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>

void*
thread(void* ptr)
{
for (int i = 0; i < 3; i++) {
sleep(1);
printf("This is a pthread.\n");
}

pthread_exit(0);

return 0;
}

int
main()
{
pthread_t id;
if (pthread_create(&id, NULL, thread, NULL)) {
printf("Create pthread error!\n");
return 1;
}

for (int i = 0; i < 3; i++) {
printf("This is the main process.\n");
sleep(1);
}

pthread_join(id, NULL);

return 0;
}

线程同步

POSIX 信号量

1
2
3
4
5
6
#include <semaphore.h>
int sem_init(sem_t* sem, int pshared, unsigned int value);
int sem_destroy(sem_t* sem);
int sem_wait(sem_t* sem);
int sem_trywait(sem_t* sem);
int sem_post(sem_t* sem);

sem:被操作的信号量

pshared:取值为 0 表示为当前进程的局部信号量,否则可以在多个进程间共享

value:指定信号量初始值,0 或者更多

sem_init()用于初始化一个未命名的信号量

sem_destroy()用于销毁信号量

sem_wait()用于将信号量-1(原子操作),即 P 操作;当信号量为 0 时,阻塞

sem_trywait()用于非阻塞的 P 操作;当信号量为 0 时返回-1 并设置 errno

sem_post()用于将信号量 +1(原子操作),即 V 操作;当信号量大于 0 时,将唤醒其他等待该信号量的阻塞进程

以上成功时返回 0,失败返回-1 并设置 errno


互斥锁

1
2
3
4
5
6
#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t* mutex, const pthread_mutexattr_t* mutexattr);
int pthread_mutex_destroy(pthread_mutex_t* mutex);
int pthread_mutex_lock(pthread_mutex_t* mutex);
int pthread_mutex_trylock(pthread_mutex_t* mutex);
int pthread_mutex_unlock(pthread_mutex_t* mutex);

mutex:被操作的互斥锁

mutexattr:初始化时互斥锁的属性;设置为NULL时使用默认属性

pthread_mutex_init()用于初始化互斥锁

pthread_mutex_destroy()用于销毁互斥锁

pthread_mutex_lock()用于加锁(原子操作);当已上锁时阻塞

pthread_mutex_trylock()非阻塞的上锁操作;已上锁时返回错误码EBUSY

pthread_mutex_unlock()用于解锁(原子操作);当其他线程等待这个互斥锁时将唤醒某一个线程

以上成功时返回 0,失败返回错误码

互斥锁通过pthread_mutexattr_t结构体来定义其属性,并通过函数操作来设置属性

1
2
3
4
5
6
7
8
9
10
11
#include <pthread.h>
// 初始化互斥锁对象属性
int pthread_mutexattr_init(pthread_mutexattr_t* attr);
// 销毁互斥锁对象属性
int pthread_mutexattr_destroy(pthread_mutexattr_t* attr);
// 获取和设置互斥锁的pshared属性
int pthread_mutexattr_getpshared(const pthread_mutexattr_t* attr, int* pshared);
int pthread_mutexattr_setpshared(pthread_mutexattr_t* attr, int pshared);
// 获取和设置互斥锁的type属性
int pthread_mutexattr_gettype(const pthread_mutexattr_t* attr, int* type);
int pthread_mutexattr_settype(pthread_mutexattr_t* attr, int type);

是否允许跨进程共享互斥锁:pshared

PTHREAD_PROCESS_SHARED:互斥锁可以跨进程共享

PTHREAD_PROCESS_PRIVATE:现有一线程用来初始化互斥锁,与此线程隶属于同一个进程的其他线程可以共享互斥锁,即不可以跨进程共享

指定互斥锁的类型:type

PTHREAD_MUTEX_NORMAL:普通锁,默认类型;请求锁的线程形成顺序等待队列;对已经加锁的锁再次加锁,将死锁;对已经解锁的锁再次解锁,将导致不可预知的错误

PTHREAD_MUTEX_ERRORCHECK:检错锁;对已经加锁的锁再次加锁,返回错误码EDEADLK;对已经解锁的锁再次解锁,返回错误码EPERM

PTHREAD_MUTEX_RECURSIVE:嵌套锁;对一个锁可以多次加锁,相应的解锁时也需要多次解锁

PTHREAD_MUTEX_DEFAULT:默认锁


条件变量

1
2
3
4
5
6
#include <pthread.h>
int pthread_cond_init(pthread_cond_t* cond, const pthread_condattr_t* cond_attr);
int pthread_cond_destroy(pthread_cond_t* cond);
int pthread_cond_broadcast(pthread_cond_t* cond);
int pthread_cond_signal(pthread_cond_t* cond);
int pthread_cond_wait(pthread_cond_t* cond, pthread_mutex_t* mutex);

cond:被操作的条件变量

cond_attr:初始化时条件变量的属性;设置为NULL时使用默认属性

pthread_cond_init()用于初始化条件变量

pthread_cond_destroy()用于销毁条件变量

pthread_cond_broadcast()用于以广播方式唤醒所有等待条件变量的线程

pthread_cond_signal()用于唤醒一个等待条件变量的线程

pthread_cond_wait()用于等待条件变量;使用前需要以mutex加锁以保证原子性

以上成功时返回 0,失败返回错误码

多进程编程

作者 Noicdi
2021年7月23日 10:01

僵尸进程

僵尸态:子进程结束运行后,内核没有立即释放该进程的进程表表项,用以满足父进程后续对子进程退出信息的查询

在子进程结束之后、父进程读取退出状态之前(父进程正常运行),称之为僵尸态;父进程退出之后(下文)、子进程退出之前,称之为僵尸态

父进程结束或异常终止,子进程仍在运行时,改变其 PPID 为 1(init 进程),由 init 进程等待子进程结束

一般通过wait()waitpid()处理僵尸进程

1
2
3
4
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int* stat_loc);
pid_t waitpid(pid_t pid, int* stat_loc, int options);

wait()将父进程阻塞,直到任意一个子进程结束运行。返回结束运行的子进程的 PID,并保存退出状态信息到参数stat_loc指向的内存中

waitpid()通过参数pid指定等待的子进程,取值为-1 则表示如同wait()一样等待任意子进程结束;参数stat_loc保存退出状态信息;参数options指定控制行为,一般取值为WNOANG,表示非阻塞调用

相关参考


进程间通信

管道

父子进程通过管道传递数据,且管道只能用于有关联的两个进程之间。本质上是pipe()通过管道文件描述符fd[1]向内核缓冲区写入数据,fd[0]从内核缓冲区读出数据,为半双工方式。缓冲区大小有限,写满或者读空时,需要等待读出或者写入

管道容量的默认大小是 65536B,可以通过fcntl()修改容量

1
2
#include <unistd.h>
int pipe(int fd[2]);

image-20210723123510225

通过构建两个管道实现双向通信;或者通过 socket 编程提供的sockpair()

1
2
3
#include <sys/types.h>
#include <sys/socket.h>
int socketpair(int domain, int type, int protocol, int fd[2]);

前三个参数与socket()的参数含义一致,但domain只能使用 UNIX 本地域协议族AF_UNIX;文件描述符fd是既可读又可写的


有名管道

保存在文件系统中

1
2
3
# mkfifo myPipe
# ls -l myPipe
prw-r--r-- 1 z z 0 5月 23 12:17 myPipe

信号量

用户进程通过使用 OS 提供的一对原语来对信号量进行操作,实现进程同步与互斥

原语:一种特殊的程序段,执行时不可被中断,由关/开中断指令实现

一对原语:

  1. wait(S)原语,可以理解为函数,信号量 S 是调入的参数,可以写作 P(S),相当于进入临界区
  2. signal(S)原语,可以写作 V(S),相当于退出临界区

信号量可以看作变量(整数或者复杂的记录型变量),可以用信号量来表示系统中某种资源的数量,例如一台打印机的信号量初值为 1

可以看作一种计数器,用来为多个进程提供对共享数据对象的访问

❗ 通过semget()将某个信号量生成信号量标识符,通过semop()执行 P、V 操作,通过semctl()控制信号量的某些属性


semget()创建一个新的信号量集,或获得一个已存在的信号量集

1
2
#include <sys/sem.h>
int semget(key_t key, int num_sems, int sem_flags);

成功时返回一个正整数值,作为信号量集的标识符;错误时返回-1 并设置 errno

key:用来标识一个全局唯一的信号量集,它代表程序可能使用的某个资源,通过信号量通信的进程使用相同的key来 | 创建 | 获取 | 该信号量。通过semget()key,由系统生成一个信号量标识符,程序通过信号量标识符来使用信号量

num_sems:指定要 | 创建 | 获取 | 的信号量集中信号量的数目。如果是创建信号量,则该值必须被指定,一般都是 1;如果是获取已经存在的信号量,则可以把它设置为 0

sem_flags:指定一组标志。作为参数的信号量不存在时,想要创建一个新的信号量,可以将sem_flags和值IPC_CREAT按位或操作;而IPC_CREAT | IPC_EXCL可以创建一个新的、唯一的信号量,如果信号量已存在,报错

下图为sem_flags可以的取值,一般直接设置为0666

image-20210723160659513

例:

1
2
// 创建信号量
int sem_id = semget((key_t)1234, 1, 0666|IPC_CREAT);

semctl()控制信号量信息

1
2
#include <sys/sem.h>
int semctl(int sem_id, int sem_num, int command, ...);

sem_id:表示semget()返回的信号量集的标识符

sem_num:表示信号量集中信号量的编号,0 为第一个。一般来说都设置为 0

command:表示要执行的命令,第三个参数取值SETVAL(把信号量初始化为一个已知的值)或IPC_RMID(删除一个已经无需继续使用的信号量标识符);第四个参数通常是一个union semum结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
union semun {
int val;
struct semid_ds* buf;
unsigned short* array;
struct seminfo* __buf;
};

struct seminfo {
int semmap;
int semmni;
int semmns;
int semmnu;
int semmsl;
int semopm;
int semume;
int semusz;
int semvmx;
int semasm;
};

一般需要利用semctl()初始化信号量,这是使用前必须做的

例:

1
2
3
4
5
6
7
8
// 创建信号量
int sem_id = semget((key_t)1234, 1, 0666|IPC_CREAT);

union semmun sem_union;
sem_union.val = 1;
if(semctl(sem_id, 0, SETVAL, sem_union) == -1) {
return -1; // 初始化失败
}

semop()改变信号量的值,即 P、V 操作,实际上是对内核变量的操作

1
2
#include <sys/sem.h>
int semop(int sem_id, struct sembuf* sem_ops, size_t num_sem_ops);

sem_idsemget()返回的信号量集的标识符

sem_ops:指向sembuf结构体数组

1
2
3
4
5
struct sembuf {
unsigned short int sem_num;
short int sem_op;// 操作类型
short int sem_flg;// IPC_NOWAIT 或 SEM_UNDO
};

sem_num:表示信号量集中信号量的编号,0 为第一个。一般来说,传入的单个信号量,此时设置为 0;如果使用信号量集,根据编号设置

sem_op:表示操作类型。一般用1表示 V 操作(即退出临界区);用-1表示 P 操作(即进入临界区)

sem_flgIPC_NOWAITSEM_UNDOIPC_NOWAIT表示无论调用是否成功,立刻返回,类似于非阻塞 I/O;SEM_UNDO表示操作系统跟踪信号,进程未释放信号量而终止时,操作系统释放信号量

num_sem_opssem_ops中信号量的个数

例:

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
// 创建信号量
int sem_id = semget((key_t)1234, 1, 0666|IPC_CREAT);

union semmun sem_union;
sem_union.val = 1;
if(semctl(sem_id, 0, SETVAL, sem_union) == -1) {
return -1; // 初始化信号量失败
}

struct sembuf sem_b;
sem_b.sem_num = 0;
sem_b.sem_op = -1;// 此时表示P操作
sem_b.sem_flg = SEM_UNDO;
if(semop(sem_id, &sem_b, 1) == 1) {
// 执行P操作,进入临界区
// 在临界区中执行操作
}

// 准备退出临界区
sem_b.sem_op = 1; // 此时表示V操作
if(semop(sem_id, &sem_b, 1) != 1) {
// 执行V操作,退出临界区
printf("退出临界区失败");
return -1;
}

if(semctl(sem_id, 0, IPC_RMID, sem_union) == -1) {
return -1;// 删除信号量失败
}

共享内存

在通信进程的虚拟内存空间中,拿出一块虚拟地址空间,映射到相同的物理内存中

image-20210523123011884

  • 一般利用 | 信号量 | 记录锁 | 互斥量 | 来同步访问共享内存

❗ 通过shmget()申请一段新的共享内存,通过shmat()关联,shmdt分离;shmctl()控制共享内存的某些属性


shmget()创建一个新的共享内存,或获得一个已存在的共享内存

1
2
#include <sys/shm.h>
int shmget(key_t key, size_t size, int shmflg);

成功时返回一个正整数值,作为共享内存的标识符;错误时返回-1 并设置 errno

key:用来标识一个全局唯一的共享内存,通过shmget()key,由系统生成一个共享内存标识符,程序通过共享内存标识符来使用共享内存

size:以 B 为单位指定需要共享的内存容量

shmflg:与信号量中semget()中的参数semflg类似,也是可以利用IPC_CREAT做按位或操作

例:

1
2
3
// 创建共享内存记录结构体shared_test
struct shared_test* ptr;
int sem_id = shmget((key_t)1234, sizeof(shared_test), 0666|IPC_CREAT);

shmat()在进程中关联新创建的共享内存;shmdt()在进程中分离共享内存

1
2
3
#include <sys/shm.h>
void* shmat(int shm_id, const void* shm_addr, int shmflg);
int shmdt(const void* shm_addr);

shm_idshmget()中返回的共享内存标识符

shm_addr:指定共享内存关联到当前进程的地址,一般设置为 0,表示由系统决定

shmflg:一组标志位,一般设置为 0,配合shm_addr设置为 0,来由系统决定关联地址

shmat()返回指向共享内存所关联的地址的指针;失败返回-1。类似于malloc()申请动态内存

shmdt()由关联动态内存的指针,在本进程中分离动态内存(只是本进程不能使用,并不是删除动态内存)

例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 创建共享内存记录结构体shared_test
struct shared_test* ptr;
int sem_id = shmget((key_t)1234, sizeof(shared_test), 0666|IPC_CREAT);

void* shm_ptr = shmat(shm_id, 0, 0);// 系统决定关联地址
if(shm_ptr == (void*)-1) {
return -1;
}
ptr = (struct shared_test*)shm_ptr;
// 针对内存的读写操作
// 其他进程也是如此
if(shmdt(ptr) == -1) {
return -1;// 分离共享内存失败
}

shmctl()控制共享内存的属性

1
2
#include <sys/shm.h>
int shmctl(int shm_id, int command, struct shmid_ds* buf);

shm_idshmget()中返回的共享内存标识符

command:表示要执行的命令

IPC_STAT:复制共享内存的当前关联值到shmid_ds

IPC_SET:当进程拥有权限时,设置shmid_ds中的属性到共享内存的关联值中

IPC_RMID:删除当前共享内存

buf:指向用于复制或更改属性的shmid_ds

例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 创建共享内存记录结构体shared_test
struct shared_test* ptr;
int sem_id = shmget((key_t)1234, sizeof(shared_test), 0666|IPC_CREAT);

void* shm_ptr = shmat(shm_id, 0, 0);// 系统决定关联地址
if(shm_ptr == (void*)-1) {
return -1;
}
ptr = (struct shared_test*)shm_ptr;
// 针对内存的读写操作
// 其他进程也是如此
if(shmdt(ptr) == -1) {
return -1;// 分离共享内存失败
}

if(shmctl(shm_id, IPC_RMID, 0) == -1) {
return -1;// 删除共享内存失败
}

❗:一般在操作共享内存时,可以搭配信号量实现读写的互斥访问;但是有的程序可以放弃使用信号量,让多个进程同时进行读操作,提高效率,这就是进程同步问题的读者-写者问题


消息队列

直接通信方式:发送进程利用发送原语将消息直接发送到接收进程的消息缓冲队列,由接收进程利用接收原语接收消息

间接通信方式:发送进程利用发送原语将消息发送到中间实体(信箱),接收进程利用接收原语将信箱中属于自己的消息接收;消息的消息头中包含了发送和接收进程的 ID 等内容,不用担心接收错误

image-20210523122619246

  • 消息队列可以独立于发送进程和接收进程而存在
  • 接收程序可以选择接收消息队列中的特定类型的信息

❗ 通过msgget()创建一个消息队列;利用msgsnd()挂载消息到队列上,利用smgrcv()从队列上摘取消息;msgctl()控制消息队列的某些属性


msgget()创建一个消息队列,或者获取一个已有的消息队列

1
2
#include <sys/msg.h>
int msgget(key_t key, int msgflg);

成功时返回一个正整数值,作为消息队列的标识符;错误时返回-1 并设置 errno

key:用来标识一个全局唯一的消息队列,通过msgget()key,由系统生成一个消息队列标识符,程序通过消息队列标识符来使用消息队列

msgflg:与信号量中semget()中的参数semflg类似,也是可以利用IPC_CREAT做按位或操作;当消息队列存在时,IPC_CREAT被忽略,返回标识符

例:

1
2
// 创建消息队列
int msg_id = msgget((key_t)1234, 0666|IPC_CREAT);

msgsnd()挂载消息到队列上,smgrcv()从队列上摘取消息

1
2
3
4
5
6
7
8
9
#include <sys/msg.h>
int msgsnd(int msg_id, const void* msg_ptr, size_t msg_sz, int msgflg);
int msgrcv(int msg_id, const void* msg_ptr, size_t msg_sz, long int msgtype,
int msgflg);

struct msgbuf {
long mtype;// 消息类型
char mtext[512];// 消息数据
};

msg_idmsgget()中返回的消息队列标识符

msg_ptr:指向一个准备 | 发送 | 存储 | 的消息,消息必须被定义为msgbuf这种结构

msg_sz:表示消息数据的长度。如上所示则长度为 512;如设置为 0 则表示没有消息

msgflg:一组标志位,可以按位或

msgsnd()中:默认为 0,消息队列满时将阻塞,直到成功;若设置为IPC_NOWAIT,表示为非阻塞方式,消息队列满时将立即返回并设置 errno

msgrcv()中:设置为IPC_NOWAIT,表示非阻塞方式;设置为MSG_NOERROR,表示消息长度超过msg_sz时截断;设置为MSG_EXCEPT,且msgtype大于 0,表示接收消息队列中第一个非msgtype类型的消息

msgtype:指定接收何种类型的消息

等于 0:读取消息队列中的第一个消息

大于 0:读取消息队列中第一个类型为msgtype类型的消息(msgflg指定了MSG_EXECPT的情况下则不同)

小于 0:读取消息队列中第一个类型值比msgtype小的消息

两个系统调用成功时返回 0;失败则返回-1 并设置 errno

例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct msgbuf {
long mtype;
char mtext[512];
};

// 创建消息队列
int msg_id = msgget((key_t)1234, 0666|IPC_CREAT);

char* buffer = "Hello world";
struct msgbuf data;
data.mtype = 1;
strncpy(&data.mtext, buffer, strlen(buffer)+1);

if(msgsnd(msg_id, (void*)&data, 512, 0) == -1) {
// 阻塞方式发送信息
return -1;// 发送消息失败
}

if(msgrcv(msg_id,(void*)&data, 512, 0, 0) == -1) {
// 接收消息队列中的第一个消息
return -1; // 接收消息失败
}

msgctl()控制消息队列的某些属性

1
2
#include <sys/msg.h>
int msgctl(int msg_id, int command, struct msgid_ds* buf);

msg_idmsgget()中返回的消息队列标识符

command:表示要执行的命令

IPC_STAT:复制消息队列的当前关联值到msgid_ds

IPC_SET:当进程拥有权限时,设置msgid_ds中的属性到消息队列的关联值中

IPC_RMID:删除当前消息队列

buf:指向用于复制或更改属性的msgid_ds

例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct msgbuf {
long mtype;
char mtext[512];
};

// 创建消息队列
int msg_id = msgget((key_t)1234, 0666|IPC_CREAT);

char* buffer = "Hello world";
struct msgbuf data;
data.mtype = 0;
strncpy(&data.mtext, buffer, strlen(buffer)+1);

if(msgsnd(msg_id, (void*)&data, 512, 0) == -1) { // 阻塞方式发送信息
return -1;// 发送消息失败
}

if(msgrcv(msg_id,(void*)&data, 512, 0, 0) == -1) {
return -1; // 接收消息失败
}

if(msgctl(msg_id, IPC_RMID, 0) == -1){
return -1; // 删除消息队列失败
}

在进程间传递文件描述符

父进程打开的文件,通过fork()后将文件描述符复制到子进程中,但是子进程传递到父进程则不行

要点在于,传递文件描述符,并不是简单的将文件描述符的值复制给另一个进程,而是通过文件描述符指向相同的文件。进程有file_struct中的指针数组的索引做文件描述符,应当在进程间传递这样的结构

可以通过 UNIX 域 socket 在进程间传送,参考sockpair()

❌
❌