shyJyt's Blog

shyJyt's Blog

賽博筆記本

几个基本概念

模块

不被直接执行的 py 文件, __name__ 为其包名和模块名拼成。

包含可执行语句及函数定义。这些语句用于初始化模块,仅在 import 语句第一次遇到模块名时执行。

脚本

直接执行的 py 文件,__name____main__,也叫主模块

包含 __init__.py 的文件夹

引入

Python 解释器在导入模块时遵循以下搜索顺序,其实也就是 sys.path 的初始值。

  1. 被命令行直接运行的脚本所在的目录 或 未指定文件时的当前目录

    未指定文件的情况:python -m module,即以模块方式运行指定 module

  2. 环境变量 PYTHONPATH 中指定的路径列表中搜索(除了标准库之外的额外搜索目录)

  3. 标准库

可以通过修改 sys.path 值来添加搜索路径,但要注意若要添加相对路径,则是相对工作目录的,因为这里的相对是基于文件系统的。

绝对引入

由包名和模块名共同组成,包也应当在可被搜索的范围内。

相对引入

相对导入基于当前模块名,不能通过此方式引入高于顶层包的包。因为主模块名永远是 __main__ ,换句话说,主模块所在文件夹不被视为范围内的包(高于顶层包),主模块同目录下的模块也视为顶层模块(__name__ 仅包含文件名,不包含包名),主模块同级的包被视为顶层包,所以如果一个模块会作为顶级模块(自己或同目录模块被直接执行),那么该模块的导入语句必须始终使用绝对导入

其他

想要在包内部全部使用相对引用,只有执行脚本 main.py 使用绝对引用,以下是个可行的结构,关键在于 main.py 并不在 src 内部,整个 src 被视为顶层包。

image-20240716165729792

由于云平台的服务器经常会断网,得写个脚本定时登录。

登录脚本

1
2
3
4
5
6
7
8
9
#!/bin/bash

# cd to the position of shell file
# 保证在不同路径下执行该脚本(因为使用了相对路径)的一致性
cd `dirname $0`

# cron 执行时使用的环境变量不包含 buaalogin,需绝对路径引用
# 由于已经切换到脚本所在目录,故相对路径生成的日志文件和脚本同目录
/usr/local/bin/buaalogin >> ./auto_login.log

crontab 配置定时任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 修改任务列表
crontab -e

# 在文件末尾加上新任务
# 每 30 分钟执行一次 buaalogin
# 分 时 日 月 周几
# /a 表示每过 a 个单位时间执行
*/30 * * * * /home/buaa/LinkedOut_Deployment/auto_login/auto_login.sh

# 重启 cron 服务
sudo service cron restart

# 列出...
crontab -l

默认是不打印 cron 日志的,修改 /etc/rsyslog.d/50-default.conf ,将 cron.* 的注释取消,重启 rsyslog

1
sudo service rsyslog restart

安装 postfix 接收 cron 的报错信息

不安装邮件传输代理 (MTA) 会出现如下 info:

image-20240630110236173

安装 postfix 后可看到具体报错,如由于路径 / 环境变量 导致的找不到命令 / 文件等等,在 /var/spool/mail/username 中。

前端

build 成 dist 上传到服务器即可

后端

1、项目文件上传,创建虚拟环境,安装对应包

注意 uwsgi 需要用 conda install ,如果还有问题,采用以下命令安装。

1
2
3
4
5
anaconda search -t conda uwsgi

anaconda show conda-forge/uwsgi

conda install --channel https://conda.anaconda.org/conda-forge uwsgi

2、uwsgi:配置 uwsgi.ini

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[uwsgi]
socket = 127.0.0.1:8000
chdir = /home/buaa/LinkedOut_Backend
wsgi-file = /home/buaa/LinkedOut_Backend/LinkedOut_Backend/wsgi.py
master = true
enable-threads = true
processes = 8
buffer-size = 65536
vacuum = true
daemonize = /home/buaa/LinkedOut_Deployment/backend/uwsgi/uwsgi.log
virtualenv = /home/buaa/miniconda3/envs/LinkedOut
uwsgi_read_timeout = 600
threads = 2
chmod-socket = 664
pidfile = /home/buaa/LinkedOut_Deployment/backend/uwsgi/uwsgi.pid

3、nginx:配置 web.conf

注意配置请求体的大小限制 client_body_buffer_size 10m; client_max_body_size 20m; 以避免 403 (请求体过大)错误,默认是 1M。

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

location / {
root /home/buaa/dist;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}

location /api {
include /etc/nginx/uwsgi_params;
uwsgi_pass 127.0.0.1:8000;
client_body_buffer_size 10m;
client_max_body_size 20m;
}

location /ws {
proxy_pass http://127.0.0.1:8001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_read_timeout 360s;
}

error_page 497 https://$host$uri?$args;
}

部署脚本(半自动

因为暂未找到在 bash 脚本中启动 conda 虚拟环境的方法,故仍需手动执行命令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
cd /home/buaa/LinkedOut_Backend
echo $(pwd)

# conda
conda activate LinkedOut

# stop previous service
sudo pkill -f uwsgi -9
sudo pkill -f dephne -9

# get latest version
git checkout dev
git pull

# migrate
python manage.py makemigrations
python manage.py migrate

# start uwsgi
cd /home/buaa/LinkedOut_Deployment/backend
echo $(pwd)
uwsgi --ini ./uwsgi/uwsgi.ini

daphne 默认占用前台,要么 tmux 新开窗口,要么采用 supervisor 后台跑。

1
2
3
4
5
6
7
8
# activate conda venv
conda activate LinkedOut

sudo pkill -f dephne -9

# start daphne
cd /home/buaa/LinkedOut_Backend
daphne LinkedOut_Backend.asgi:application -p 8001

最后重启 nginx 即可。

1
sudo nginx -s reload

附:常用命令

nginx

1
2
3
4
5
6
7
8
# 查看状态
sudo systemctl status nginx
# 启动
sudo systemctl start nginx
# 重启
sudo nginx -s reload
# 停止
sudo nginx -s stop

uwsgi

1
2
3
4
5
6
7
8
9
# 注意不要用 sudo,不然有日志里面可以看到有 warning,但是这样就必须给 ubuntu 足够权限
# 可能是上传时用的 root 用户,所以文件所有者就给了 root,此时 ubuntu 再操作就没权限了
sudo chmod -R 777 ./*
uwsgi ini --uwsgi.ini

uwsgi --stop uwsgi.pid
sudo pkill -f uwsgi -9

ps aux | grep uwsgi

daphne

1
2
3
4
sudo pkill -f dephne -9

cd /home/buaa/LinkedOut_Backend
daphne LinkedOut_Backend.asgi:application -p 8001

mysql

可能默认用户不是 root,去配置文件里面看看。而且默认配置 bind 127.0.0.1,注释掉才可允许远程连接。

1
mysql -u root -p

redis

同上 mysql,还得将配置文件中的 protected-mode yes 改为 no,redis 默认是没有密码登录的,如果允许了远程访问肯定是不安全的,还是得设置一下密码。

1
2
3
4
5
6
7
# 查看状态
sudo systemctl status redis-server
# 重启
sudo systemctl restart redis-server
# 登录
redis-cli
auth 123456

fork —— 创建新进程

完整的进程状态机复制,包括缓冲区(一块地址空间而已)等等。如果程序的缓冲区未及时清空,就会出现神奇的现象。

1
2
3
4
5
6
7
8
9
#include <unistd.h>
#include <stdio.h>

int main() {
for (int i = 0; i < 2; i++) {
fork();
printf("Hello\n");
}
}

image-20240429172703992

为了减少 write 系统调用的次数,printf 采用以下三种缓冲策略:

  • unbuffered(不缓冲,来一个输出一个)
  • line buffered(常见于终端,遇到换行符则刷新)
  • fully buffered(输出重定向或者管道)

正是由于采用了不同的策略导致 fork 的时候是否会将未及时输出的 Hello 复制一份给子进程。

execve —— 加载可执行文件

ELF 可执行文件描述了进程的初始状态,加载可执行文件就是将进程的状态重置为可执行文件的状态。

为什么要有第一个 init 进程??

跨平台的应用程序需要考虑 OS 和体系结构的不同组合吗?

OS 的实现会跟着体系结构走,按道理只用考虑体系结构就行了

为什么说 NVIDIA 的 Linux 驱动不好?

为什么处理时钟中断过程中,已经有了 trapframe 还需要保存一次上下文?时钟中断处理程序执行过程中会修改 trapframe 中的值?

首先需要明确两点:

1、alarm_handler 在用户态执行

2、每次 trap(系统调用、中断、异常)都会刷新 trapframe 的内容,使其为当前进程的最新上下文

如果不另存一份调用 alarm_handler 之前的上下文,在 alarm_handler 使用系统调用 sys_sigreturn() 或者 alarm_handler 执行时间较长再次发生时钟中断时,都会因为 trap 而将现在的进程上下文刷入 trapframe 中,无法恢复到 alarm_handler 执行前的状态。

make grade

image-20240428110052525

虚拟地址和物理地址的转换在什么时候发生?

物理内存是指 DRAM 中的存储单元。物理内存的一个字节有一个地址,称为物理地址。指令只使用虚拟地址,分页硬件将其转换为物理地址,然后发送到 DRAM 硬件以读取或写入存储。

操作系统是如何分页硬件沟通的?

内核必须将根页表页的物理地址写入 satp 寄存器。每个 CPU 都有自己的 satp, CPU 将使用自己的satp指向的页表,转换后续指令产生的所有地址。每个CPU都有自己的satp,使得不同的CPU可以运行不同的进程,每个进程都有一个私有地址空间,由自己的页表描述。通常,内核将所有物理内存映射到其页表中,这样就可以使用加载/存储指令读写物理内存中的任何位置。由于页目录在物理内存中,内核可以使用标准的存储指令向页目录的虚拟地址写入数据,从而对页目录中页目录的内容进行编程。

内核空间和进程的内核栈有什么关系?

解析一个 va 的完整流程

Speed up system calls

思考题1

思考题2

作为页表页的物理页没有特殊标记吗?通过深度控制页表层次灵活性不够。

一个地址的 uint64 和 uint64 * 的不同用法

指针赋予了变量进行地址运算的能力。

如何用 gdb 调试 xv6

比如说在第一个实验中,出现 panic: freewalk leaf ,如何找到错误?

先在实验根目录 make qemu-gdb 启动 qemu 调试模式,再在另一个终端 gdb-multiarch -x .gdbinit 启动 gdb 并以 .gdbinit 文件配置的内容初始化。

1
2
3
4
5
6
7
8
# .gdbinit
set confirm off
set architecture riscv:rv64
target remote 127.0.0.1:26000
# 设定符号文件为 kernel,即编译后的内核可执行文件
symbol-file kernel/kernel
set disassemble-next-line auto
set riscv use-compressed-breakpoints yes

kernel/kernel 中包含了所有内核函数,可通过函数名下断点,其更具体的内容在 kernel.asm 中。继续执行后可观察到 qemu 启动,剩余部分就是常规的 gdb 调试了。

为什么在释放页表时需要解除映射?

1
2
3
4
5
6
7
8
9
10
11
12
void
proc_freepagetable(pagetable_t pagetable, uint64 sz)
{
uvmunmap(pagetable, TRAMPOLINE, 1, 0);
uvmunmap(pagetable, TRAPFRAME, 1, 0);

#ifdef LAB_PGTBL
uvmunmap(pagetable, USYSCALL, 1, 0);
#endif

uvmfree(pagetable, sz);
}

PTE_* 是在什么时候被设置的?为什么 PTE_A 在访问完后要清除?

make grade

image-20240426225954867

1. xv6 启动流程

​ 当 RISC-V 计算机启动时,它初始化自己并运行存储在 ROM 中的引导加载程序。引导加载程序将 xv6 内核(镜像文件)加载到内存中指定位置 0x80000000,此时分页并未启用,虚拟地址直接映射到物理地址。借助 kernel.ld 链接脚本将下列代码放在指定位置,然后,在机器模式下,CPU 从 _entry 开始执行xv6。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
	# qemu -kernel loads the kernel at 0x80000000
# and causes each CPU to jump there.
# kernel.ld causes the following code to
# be placed at 0x80000000.
.section .text
.global _entry
_entry:
# set up a stack for C.
# stack0 is declared in start.c,
# with a 4096-byte stack per CPU.
# sp = stack0 + (hartid * 4096)
la sp, stack0
li a0, 1024*4
csrr a1, mhartid
addi a1, a1, 1
mul a0, a0, a1
add sp, sp, a0
# jump to start() in start.c
call start
spin:
j spin

​ 之所以将内核放置在 0x80000000 而不是 0x0,是因为地址范围 0x0 : 0x80000000 包含了 I / O 设备。_entry处的指令建立了一个栈(允许函数调用),使 xv6 可以运行 C 代码。xv6 在文件 start.c 中为初始栈 stack0 声明了空间。现在内核有了栈,开始时调用C代码。

​ start() 函数执行一些只允许在机器模式下执行的配置,然后切换到 supervisor 模式。为了进入 supervisor 模式,RISC-V 提供了 mret 指令。该指令通常用于从上一次调用从 supervisor 模式返回到机器模式。start() 不会从这样的调用中返回,它在寄存器 mstatus 中将之前的特权模式设置为 supervisor,通过将 main() 的地址写入寄存器 mepc 来将返回地址设置为 main,通过将页表寄存器 satp 写入 0 来禁用 supervisor 模式下的虚拟地址转换,并将所有中断和异常委托给 supervisor 模式。在进入 supervisor 模式之前,start 还要时钟芯片进行编程,以产生定时器中断。有了这个清理工作,通过调用 mret 开始“返回”到 supervisor 模式。

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
// entry.S jumps here in machine mode on stack0.
void
start()
{
// set M Previous Privilege mode to Supervisor, for mret.
unsigned long x = r_mstatus();
x &= ~MSTATUS_MPP_MASK;
x |= MSTATUS_MPP_S;
w_mstatus(x);

// set M Exception Program Counter to main, for mret.
// requires gcc -mcmodel=medany
w_mepc((uint64)main);

// disable paging for now.
w_satp(0);

// delegate all interrupts and exceptions to supervisor mode.
w_medeleg(0xffff);
w_mideleg(0xffff);
w_sie(r_sie() | SIE_SEIE | SIE_STIE | SIE_SSIE);

// configure Physical Memory Protection to give supervisor mode
// access to all of physical memory.
w_pmpaddr0(0x3fffffffffffffull);
w_pmpcfg0(0xf);

// ask for clock interrupts.
timerinit();

// keep each CPU's hartid in its tp register, for cpuid().
int id = r_mhartid();
w_tp(id);

// switch to supervisor mode and jump to main().
asm volatile("mret");
}

​ 在main () 初始化几个设备和子系统之后,它调用 userinit() 创建第一个用户态进程。第一个进程执行一个 initcode.S,它调用了 exec 启动了 shell 来代替当前进程。

​ 至此启动完成了。

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
// start() jumps here in supervisor mode on all CPUs.
void
main()
{
if(cpuid() == 0){
consoleinit();
printfinit();
printf("\n");
printf("xv6 kernel is booting\n");
printf("\n");
kinit(); // physical page allocator
kvminit(); // create kernel page table
kvminithart(); // turn on paging
procinit(); // process table
trapinit(); // trap vectors
trapinithart(); // install kernel trap vector
plicinit(); // set up interrupt controller
plicinithart(); // ask PLIC for device interrupts
binit(); // buffer cache
iinit(); // inode table
fileinit(); // file table
virtio_disk_init(); // emulated hard disk
userinit(); // first user process
__sync_synchronize();
started = 1;
} else {
while(started == 0)
;
__sync_synchronize();
printf("hart %d starting\n", cpuid());
kvminithart(); // turn on paging
trapinithart(); // install kernel trap vector
plicinithart(); // ask PLIC for device interrupts
}

scheduler();
}
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
// a user program that calls exec("/init")
// od -t xC initcode
uchar initcode[] = {
0x17, 0x05, 0x00, 0x00, 0x13, 0x05, 0x45, 0x02,
0x97, 0x05, 0x00, 0x00, 0x93, 0x85, 0x35, 0x02,
0x93, 0x08, 0x70, 0x00, 0x73, 0x00, 0x00, 0x00,
0x93, 0x08, 0x20, 0x00, 0x73, 0x00, 0x00, 0x00,
0xef, 0xf0, 0x9f, 0xff, 0x2f, 0x69, 0x6e, 0x69,
0x74, 0x00, 0x00, 0x24, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00
};

// Set up first user process.
void
userinit(void)
{
struct proc *p;

p = allocproc();
initproc = p;

// allocate one user page and copy init's instructions
// and data into it.
uvminit(p->pagetable, initcode, sizeof(initcode));
p->sz = PGSIZE;

// prepare for the very first "return" from kernel to user.
p->trapframe->epc = 0; // user program counter
p->trapframe->sp = PGSIZE; // user stack pointer

safestrcpy(p->name, "initcode", sizeof(p->name));
p->cwd = namei("/");

p->state = RUNNABLE;

release(&p->lock);
}
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
# Initial process that execs /init.
# This code runs in user space.

#include "syscall.h"

# exec(init, argv)
.globl start
start:
la a0, init
la a1, argv
li a7, SYS_exec
ecall

# for(;;) exit();
exit:
li a7, SYS_exit
ecall
jal exit

# char init[] = "/init\0";
init:
.string "/init\0"

# char *argv[] = { init, 0 };
.p2align 2
argv:
.long init
.long 0

2. 系统调用

3. make grade

image-20240422214112132

4. 遗留问题

结构体在编译时可以只声明吗??

可以,不过当需要用到它的大小时,比如创建变量、sizeof 等等,就会报错(缺少信息,无法生成中间代码。

image-20240422232852951

在声明用户态的 sysinfo() 时为什么需要前向声明 struct sysinfo ?

声明指等到链接时再找到具体定义,其实就相当于 #include 所需头文件的一部分,而不是全部导入,由于允许重复声明而不允许重复定义,这样做防止直接 #include 带来的重复定义、循环依赖等问题。

1. 数据持久化

1.1 bind mount

1.1.1 创建

在当前目录下分别创建 log, data, conf 目录,分别持久化 mysql 的日志、数据以及配置文件,通过 -v 选项建立与容器内对应目录的映射。

1
2
3
4
5
6
docker run -it -p 8888:3306 \
-v $PWD/log:/var/log/mysql \
-v $PWD/data:/var/lib/mysql \
-v $PWD/conf:/etc/mysql/conf.d \
-e MYSQL_ROOT_PASSWORD=root \
-d mysql
1.1.2 写入数据
1
docker exec -it b10d0778405d mysql -u root -p

使用 mysql 命令行客户端,创建 test 数据库。

image-20240416104319917

1.1.3 销毁
1
2
docker stop <container-id>
docker rm <container-id>
1.1.4 重新创建

同 1.1.1

1.1.5 重新读取

上面几步的截图如下,可以看到基于干净的 mysql 镜像重新创建的容器仍然可以查询到先前创建的 test 数据库。

image-20240416105003258

1.2 volumes

1.2.1 创建
1
2
3
4
5
6
docker run -d \
-v mysql_data:/var/lib/mysql \
-v mysql_log:/var/log/mysql \
-v mysql_conf:/etc/mysql/conf.d \
-e MYSQL_ROOT_PASSWORD=root \
-d --name mysql01 mysql

由 docker 管理程序自动生成三个命名 volume .

image-20240416114259192

1.2.2 写入数据
1
docker exec -it mysql01 mysql -u root -p

同样地,使用 mysql 命令行客户端,创建 test 数据库。

image-20240416104319917

1.2.3 销毁
1
2
docker stop mysql01
docker rm mysql01
1.2.4 重新创建

同 1.2.1

1.2.5 重新读取

上面几步的截图如下,可以看到基于干净的 mysql 镜像重新创建的容器仍然可以查询到先前创建的 test 数据库。

image-20240416114738123

image-20240416114754027

1.3 方案对比

  • 性能:bind mount 方式,docker 容器直接访问 host 的目录或文件,性能更佳。
  • 权限:bind mount 方式,docker 容器对于该 host 目录可能会引入权限问题。 如果容器仅需要只读访问权限,最好是显式设定只读方式。
  • 移植性:
    • volume 方式,如果 host 中目录为空,docker 先将容器中的对应目录复制到 host 下, 然后再进行挂载操作。
    • bind mount方式,挂载之前没有复制操作,容器要依赖 host 主机的一个绝对路径, 使得容器的移植性变差。

2. 构建 Nginx 的 Ubuntu 镜像

2.1 docker commit

2.1.1 更新 apt 并换源

这里使用的是阿里云的镜像站,由于使用的是干净的 Ubuntu22 镜像,无法通过常用的 vim, nano 等命令行工具创建或编辑文件,故先在宿主机创建该源文件,借助 docker cp 在宿主机和容器间传递文件。

1
docker cp ./sources.list <container-id>:/etc/apt/

sources.list 内容如下,注释掉 deb-src 部分以提升 apt update 速度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
deb http://mirrors.aliyun.com/ubuntu/ jammy main restricted universe multiverse
# deb-src http://mirrors.aliyun.com/ubuntu/ jammy main restricted universe multiverse

deb http://mirrors.aliyun.com/ubuntu/ jammy-security main restricted universe multiverse
# deb-src http://mirrors.aliyun.com/ubuntu/ jammy-security main restricted universe multiverse

deb http://mirrors.aliyun.com/ubuntu/ jammy-updates main restricted universe multiverse
# deb-src http://mirrors.aliyun.com/ubuntu/ jammy-updates main restricted universe multiverse

# deb http://mirrors.aliyun.com/ubuntu/ jammy-proposed main restricted universe multiverse
# deb-src http://mirrors.aliyun.com/ubuntu/ jammy-proposed main restricted universe multiverse

deb http://mirrors.aliyun.com/ubuntu/ jammy-backports main restricted universe multiverse
# deb-src http://mirrors.aliyun.com/ubuntu/ jammy-backports main restricted universe multiverse

更新 apt

1
apt update
2.1.2 安装 nginx

安装 nginx

1
apt install -y nginx
2.1.3 修改 nginx 主页文件

nginx 的默认配置文件会将 /var/www/html/index.nginx-debian.html 作为主页,修改其内容包含学号。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
body {
width: 35em;
margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif;
}
</style>
</head>
<body>
<h1>==========================</h1>
<h1>My Student Number: 21371337</h1>
<h1>==========================</h1>
</body>
</html>

同样使用 docker cp 传进容器内

1
docker cp ./index.nginx-debian.html <container-id>:/var/www/html/index.nginx-debian.html
2.1.4 构建新镜像

注意修改启动命令为 nginx -g daemon off ,防止容器后台模式启动后立刻退出。

1
docker commit -c 'CMD ["nginx", "-g", "daemon off;"]' <container-id> ubuntu-nginx:dockercommit
2.1.5 本地测试

image-20240416083934662

2.1.6 登录并提交到镜像库
1
2
3
4
5
docker login --username=21371337 scs.buaa.edu.cn:8081

docker image tag ubuntu-nginx:dockercommit scs.buaa.edu.cn:8081/personal-21371337/ubuntu-nginx:dockercommit

docker image push scs.buaa.edu.cn:8081/personal-21371337/ubuntu-nginx:dockercommit

2.2 Dockerfile

2.2.1 Dockerfile
1
2
3
4
5
6
7
8
9
10
11
FROM ubuntu

COPY sources.list /etc/apt/sources.list

RUN apt update

RUN apt install -y nginx

COPY index.nginx-debian.html /var/www/html/index.nginx-debian.html

CMD ["nginx", "-g", "daemon off;"]
2.2.2 构建镜像

image-20240416091403047

2.2.3 本地测试

image-20240416090805808

2.2.4 上传

和 2.1.6 同理。

foreach 中取出的是可迭代对象的引用还是拷贝?

​ 和 C++ 的引用初始化后不能更改(也就是const A* p)不同, Java 中引用可以更改指向的对象(也就是A* p)

​ Java 中对于引用类型,变量名只是对象的一个引用(重新赋值后地址发生改变,不像C/C++中变量标识一个固定地址(也就是对象本身)需要用指针实现引用)

​ 引用类型赋值(形参传递)为引用的复制,foreach 中取出的 tmpA 是列表元素(实际是引用)的复制,相当于另创了一个引用

​ 这两个引用指向同一个对象,但原引用和新引用是相互独立的,如果修改新引用指向的对象,原引用指向的对象不会改变

​ 更加统一的说,基本数据类型和引用数据类型的赋值/传参都是值传递(深拷贝),只不过基本数据类型传递的是值,引用数据类型传递的是引用的值

​ 这一点上 Java 和 C++ 是一样的,只不过是 Java 借助引用类型实现了类似指针的功能

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
import java.util.ArrayList;
import java.util.List;

class A {
int a;

A(int a) {
this.a = a;
}
}

public class ForEachTest {
public static void main(String[] args) {
// foreach 中取出的是可迭代对象的引用还是拷贝?
List<Integer> list = new ArrayList<>();
for (int i = 0; i < 5; i++) {
list.add(i);
}
for (int tmp : list) {
tmp = 10;
}
list.forEach(System.out::println);
// List<A> list = new ArrayList<>();
//// for (int i = 0; i < 1; i++) {
//// list.add(new A(i));
//// }
//// System.out.println(list.get(0));
//// for (A tmpA : list) {
//// System.out.println(tmpA);
//// System.out.println(tmpA.a);
//// tmpA = new A(2);
//// System.out.println(tmpA);
//// System.out.println(tmpA.a);
//// }
//// // 和 C++ 的引用初始化后不能更改(也就是const A* p)不同, Java 中引用可以更改指向的对象(也就是A* p)
//// // Java 中对于引用类型,变量名只是对象的一个引用(重新赋值后地址发生改变,不像C/C++中变量标识一个固定地址(也就是对象本身)需要用指针实现引用)
// // 引用类型赋值(形参传递)为引用的复制,foreach 中取出的 tmpA 是列表元素(实际是引用)的复制,相当于另创了一个引用
// 这两个引用指向同一个对象,但原引用和新引用是相互独立的,如果修改新引用指向的对象,原引用指向的对象不会改变

// 更加统一的说,基本数据类型和引用数据类型的赋值/传参都是值传递(深拷贝),只不过基本数据类型传递的是值,引用数据类型传递的是引用的值
// 这一点上 Java 和 C++ 是一样的,只不过是 Java 借助引用类型实现了类似指针的功能

//// System.out.println(list.get(0));
//// System.out.println(list.get(0).a);
// List<StringBuilder> list = new ArrayList<>();
// list.add(new StringBuilder("a1"));
// list.add(new StringBuilder("b1"));
// list.add(new StringBuilder("c1"));
//
// for (StringBuilder tmp : list) {
// tmp.reverse();
// }
//
// list.forEach(System.out::println);
}
}

1. 线程

线程具有独立的 PC、寄存器和栈,在线程切换时和进程类似,都需要发生上下文切换,除了仍在一个进程的地址空间内。

2. lock

2.1. 怎么实现?

2.1.1. 仅凭硬件实现
  • lock() 关闭中断,unlock() 恢复中断

    • 需要对应用程序的过度信任
    • 不适用于多核处理器
    • 长时间关闭中断导致中断丢失

    适用于有限上下文中做互斥原语,例如OS内部的并发控制(OS总是信任自己的

  • old Test-And-Set (&lock, new)

    • 返回旧值,并原子地设置新值
    • 是自旋锁
  • old Compare-And-Swap(&lock, expected, new)

    • 返回旧值,如果预期值和旧值相等则写入新值
    • 比TAS更加强大
  • Load-Linked and Store-Conditional

    • 两个指令成对工作,但这两个指令作为整体并不具有原子性
    • 获取到0时说明锁未被锁定,尝试取锁,如果没有其他人更新flag,则此次写入成功,否则失败重新循环。
    • 和TAS语义上很像
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    void lock(lock_t *lock) {
    while (1) {
    // spin until it’s zero
    while (LoadLinked(&lock->flag) == 1);
    if (StoreConditional(&lock->flag, 1) == 1)
    return; // if set-to-1 was success: done
    // otherwise: try again
    }
    }

    void lock(lock_t *lock) {
    while (LoadLinked(&lock->flag) || !StoreConditional(&lock->flag, 1));
    }
  • Fetch-And-Add

    • 保证了一定的公平性,取到票后该线程一定会在未来某时刻进入临界区
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    typedef struct __lock_t {
    int ticket;
    int turn;
    } lock_t;

    void lock_init(lock_t *lock) {
    lock->ticket = 0;
    lock->turn = 0;
    }

    void lock(lock_t *lock) {
    // 每人领一张带有时间戳的票
    int myturn = FetchAndAdd(&lock->ticket);
    // 还没轮到就自旋
    while (lock->turn != myturn);
    }

    void unlock(lock_t *lock) {
    lock->turn = lock->turn + 1;
    }
2.1.2. OS参与

目的是为了避免自旋,提高性能,还能保证特定情况下的正确性(优先级翻转问题,比如说高优先级IO阻塞,低优先级获得了锁,IO完成后重新取得运行权但无法获得锁,一直自旋。。。

  • yield() 代替 spin()
    • 当前线程不能获取锁时,running -> ready
    • 线程数量较多时,就算让步之后,继续执行的也不一定是锁的持有者,而且线程切换开销大
    • 无法保证一个需要进入临界区的线程何时能够进入(饥饿)
  • sleep() 代替 spin()
    • 获取不到锁时进入阻塞队列,锁的持有者唤醒队首,需要注意的是,锁的持有者并未释放锁(除非没人阻塞在上面),相当于直接传递给了队首,被唤醒的线程直接进入临界区,而不需要再去竞争(类似于上面的FAA已经买到了票,进而不会出现饥饿。
    • 关锁和开锁都需要竞争一个自旋锁(自旋的时间是很少的,因为并不涉及用户定义的临界区代码执行)
2.1.3. 纯软件实现(Dekker、Peterson)
  • 在现代硬件上无法工作,因为宽松(Relaxed)的内存一致性模型

2.2. 如何评估

  • 正确性
  • 公平性
  • 性能
    • 单核
      • 自旋锁效率低
    • 多核
      • 自旋锁,当线程数约等于CPU数时还可以

3. Lock-based Concurrent Data Structures

一把大锁保平安,但是损失了单核到多核的扩展性(不能发挥多核优势),理应每个核和单核保持相近性能。

关键在于多核之间尽量少的争用锁。

小心控制流改变时附近的锁的获取和释放。

3.1. 并发计数器

各核利用局部锁更新本地计数器,达到阈值后尝试更新全局计数器,这个阈值会影响性能和准确性。(为什么不直接在读的时候再更新,也就是懒加载,这样就能保证准确性?

3.2. 并发链表和队列

3.3. 并发哈希表

给每个桶上一把锁,伸缩性良好。

4. Condition variable

4.1. 为什么要引入条件变量?

实现同步,即多个线程到达一个相同的状态。

4.2. join() 的朴素实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
volatile int done = 0;

void *child(void *arg) {
printf("child\n");
done = 1;
return NULL;
}

int main(int argc, char *argv[]) {
printf("parent: begin\n");
pthread_t c;
Pthread_create(&c, NULL, child, NULL); // child
// 父子线程在同一个CPU上时,自旋检查一个不会被改变的值显然是浪费
while (done == 0);
printf("parent: end\n");
return 0;
}

4.3. join() 的 CV 实现

为什么 while (done == 0)而不是if (done == 0)

详见生产者、消费者问题

为什么调用 signal() / wait() 时总要先获得锁?

对于 signal() :考虑父线程准备睡眠前被打断,切换到子线程执行 thr_exit(),再回到父线程后,父线程将无法被唤醒。当然也有不需要上锁的情况,但是总上锁是不会错的。

对于 wait() :这是语义决定的

  1. 调用时已经获得锁
  2. 睡眠后释放锁
  3. 返回前尝试获取锁(注意是尝试!!)

done 的必要性?

子线程创建后立刻执行 thr_exit(),再回到父线程后,父线程将无法被唤醒。

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
int done = 0;
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t c = PTHREAD_COND_INITIALIZER;

void thr_exit() {
Pthread_mutex_lock(&m);
done = 1;
Pthread_cond_signal(&c);
Pthread_mutex_unlock(&m);
}

void *child(void *arg) {
printf("child\n");
thr_exit();
return NULL;
}

void thr_join() {
Pthread_mutex_lock(&m);
while (done == 0)
Pthread_cond_wait(&c, &m);
Pthread_mutex_unlock(&m);
}

int main(int argc, char *argv[]) {
printf("parent: begin\n");
pthread_t p;
Pthread_create(&p, NULL, child, NULL);
thr_join();
printf("parent: end\n");
return 0;
}

4.4. 生产者、消费者问题

为什么是 while 而不是 if

wait() 的语义的第三条只是说再返回前尝试获取锁,如果在获取到锁之前有其他线程先拿到锁并改变了条件后释放锁,比如说,有个消费者被唤醒,但是来了另外一个消费者并且先一步拿到互斥锁并把东西吃掉后释放锁,那么此时之前被唤醒的线程拿到锁后应当再判断一遍真有东西吗,否则就出错了。

为什么需要两个条件变量?

signal() 只唤醒一个线程,防止消费者唤醒消费者而没有唤醒生产者导致都阻塞在同一个条件变量上,也就是 signal() 应当更准确。

只有一个条件变量真不行吗?

broadcast() 而不是 signal() 就可以了,错误唤醒的或者没抢到锁的就又睡过去了。

其实这种写法的正确性会更加的显然,jyy 推荐。

5. Semaphores

用互斥锁不能实现同步吗?

可以,用起来和大小为1的信号量一样,但是属于 Undefined Behavior ,POSIX 标准规定锁的获取和释放应当在同一个线程中完成。

信号量属于互斥锁的一个自然扩展,既可以做互斥,也可以来做同步。

在适合信号量的问题中使用会很优雅,且正确性相对容易保证,但信号量并不是万能的。

5.1. 两种典型应用

  1. 实现一次临时的 happens-before,也就是上面提到的用互斥锁来实现同步,不过这对于信号量来说没有 UB 的限制。
  2. 管理计数型资源

5.2. 生产者、消费者问题

当缓冲区大小为1时,一个信号量可以同时作为访问缓冲区的锁和缓冲区是否填满的条件变量。

如何避免死锁?

缩小互斥锁的范围

5.3. 信号量的实现

感觉很像锁 + 条件变量实现的生产者消费者。

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
typedef struct __Zem_t {
int value;
pthread_cond_t cond;
pthread_mutex_t lock;
} Zem_t;

// only one thread can call this
void Zem_init(Zem_t *s, int value) {
s->value = value;
Cond_init(&s->cond);
Mutex_init(&s->lock);
}

void Zem_wait(Zem_t *s) {
Mutex_lock(&s->lock);
while (s->value <= 0)
Cond_wait(&s->cond, &s->lock);
s->value--;
Mutex_unlock(&s->lock);
}

void Zem_post(Zem_t *s) {
Mutex_lock(&s->lock);
s->value++;
Cond_signal(&s->cond);
Mutex_unlock(&s->lock);
}

5.4. 缺陷

5.4.1. 哲学家吃饭问题

信号量可以实现(限制四人在桌上 / 先拿编号小的叉子),确保不会都拿到一只然后死锁,但是不如条件变量简单,而且随着条件的复杂性增加,条件变量的简单性会更明显。

5.4.2. 用信号量实现条件变量(失败的尝试)

谨记 wait() 的语义。

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
void wait(struct condvar *cv, mutex_t *mutex) {
mutex_lock(&cv->lock);
cv->nwait++;
mutex_unlock(&cv->lock);

// 睡眠前释放锁
mutex_unlock(mutex);

/*
如果在这里被打断,并发生 broadcast(),由于 nwait 已经加过了,所以此时 cv->sleep 变为 1,原本应当留给当前线程的球被别的线程取走,
具体地,在生产者消费者问题中,如果使用一个信号量,即使使用 broadcast() 也会出现生产者因为错误的唤醒生产者而无法唤醒应当唤醒的消费者,当唤醒的生产者发现无法生产睡去后,出现死锁。
简而言之,如此实现的 wait() 和 broadcast() 配合使用时会有问题,所以需要实现成原子操作。
*/

// 睡眠
P(&cv->sleep);

// 尝试重新获取锁
mutex_lock(mutex);
}

void broadcast(struct condvar *cv) {
mutex_lock(&cv->lock);

for (int i = 0; i < cv->nwait; i++) {
V(&cv->sleep);
}
cv->nwait = 0;

mutex_unlock(&cv->lock);
}

0%