条件变量(Condition Variable)和信号量(Semaphore)都是在多线程编程中用于同步和协调线程之间操作的机制
条件变量通常用于在某个线程等待特定条件的满足时,将其挂起,并在其他线程满足条件时唤醒它。条件变量提供了一种有效的方式来实现线程之间的通信,以及在某个条件成立时阻塞和唤醒线程。
生产者-消费者问题: 多个生产者线程和消费者线程之间的协作。当缓冲区为空时,消费者线程等待;当缓冲区非空时,生产者线程通知消费者线程可以继续消费。
考虑以下示例代码(用 Python 的 threading 模块演示,但其他编程语言和库也有类似的概念):
import threading buffer = [] buffer_size = 5 lock = threading.Lock() condition = threading.Condition(lock) def producer(): while True: item = produce_item() with lock: while len(buffer) == buffer_size: condition.wait() # 缓冲区已满,等待消费者通知 buffer.append(item) condition.notify() # 通知消费者缓冲区有数据 def consumer(): while True: with lock: while not buffer: condition.wait() # 缓冲区为空,等待生产者通知 item = buffer.pop(0) condition.notify() # 通知生产者缓冲区有空间 consume_item(item)
关键点解释:
condition.wait()
:这里是条件变量的等待操作,它会释放锁,并使得线程进入等待状态,直到其他线程调用了condition.notify()
来通知它。
condition.notify()
:这是条件变量的通知操作,它会唤醒一个正在等待的线程。在上面的例子中,生产者在放入数据后通知了等待的消费者。
忙等待的问题:
- 如果我们不使用条件变量,而是在生产者和消费者的代码中使用忙等待(比如使用
while
循环轮询缓冲区的状态),这样会导致 CPU 大量的浪费在不停地检查状态上。条件变量的作用:
- 条件变量
condition
允许线程在满足特定条件之前等待,而不是通过忙等待。在上述代码中,condition.wait()
会释放锁,并使线程进入等待状态,直到其他线程调用condition.notify()
来通知它。通过使用条件变量,线程可以有效地等待并在需要时被唤醒,而不是不断地检查某个条件,从而减少了 CPU 的浪费。
会出现唤醒混淆,当有物品可消费时,消费者应该被唤醒,而不是另一个生产者线程!!!
解决方案:
引入标志变量: 引入一个标志变量 producer_flag
,表示生产者是否可以继续生产。
import threading
buffer = []
lock = threading.Lock()
condition_producer = threading.Condition(lock)
condition_consumer = threading.Condition(lock)
producer_flag = True # 标志变量,表示生产者是否可以生产
在生产者线程中使用条件变量和标志变量:
def producer():
global producer_flag
while True:
with condition_producer:
while not producer_flag:
condition_producer.wait() # 等待生产者可以生产的通知
item = produce_item()
buffer.append(item)
print(f"Produced {item}")
producer_flag = False # 生产者生产完毕,设置标志为 False
condition_consumer.notify() # 通知一个等待的消费者线程
生产者线程首先检查 producer_flag
是否为 True
,如果不是,就等待条件变量 condition_producer.wait()
,直到有消费者线程通知它可以继续生产。
3.在消费者线程中使用条件变量和标志变量:
def consumer():
while True:
with condition_consumer:
while not buffer:
condition_consumer.wait() # 等待有物品可消费的通知
item = buffer.pop(0)
print(f"Consumed {item}")
producer_flag = True # 消费者消费完毕,设置标志为 True
condition_producer.notify() # 唤醒一个等待的生产者线程
消费者线程在成功消费一个物品后,将 producer_flag
设置为 True
,表示生产者可以继续生产。然后,通过条件变量 condition_producer.notify()
唤醒一个可能正在等待的生产者线程。
信号量是一种用于控制多个进程或线程之间访问共享资源的机制。它是一个计数器,用来表示可用资源的数量。
信号量的两个基本操作:
Wait 操作(P 操作): 当一个进程或线程希望使用共享资源时,它执行 Wait 操作。如果信号量的计数器大于零,表示有可用资源,它会减少计数器并继续执行。如果计数器为零,表示没有可用资源,它会等待,直到有资源可用。
Signal 操作(V 操作): 当一个进程或线程使用完共享资源时,它执行 Signal 操作。这会增加信号量的计数器,表示释放了一个资源。如果有其他进程或线程在等待资源,其中一个会被唤醒。
用法示例: 考虑一个简单的例子,有一个共享的打印机,多个进程想要使用它。我们可以使用信号量来控制对打印机的访问。
1. 初始化信号量为 1,表示打印机最初是可用的。 2. 进程 A 想要使用打印机: - 执行 Wait 操作,如果计数器为 1,减少计数器并继续执行(打印机被占用)。 - 如果计数器为 0,等待,直到有其他进程执行 Signal 操作释放打印机。 3. 进程 B 使用完打印机: - 执行 Signal 操作,增加计数器,表示打印机可用。 - 如果有其他进程在等待,其中一个会被唤醒,继续执行。
信号量: 适用于控制对一组资源的访问,例如限制同时访问某个资源的线程数量,或者用于实现资源池的控制。
条件变量: 适用于线程之间需要等待某个条件满足的情况,例如生产者-消费者问题、读者-写者问题等。
如果每个哲学家都拿起自己左边的餐具,那么所有人都会陷入死锁,无法进餐。因此,需要设计一个算法,以确保哲学家们能够安全地进餐。
解决方法:哲学家尝试获取左边和右边的叉子,但如果左边的叉子可用并且获取成功,右边的叉子不可用(已被其他哲学家线程持有),则释放左边的叉子,等待一段时间后再尝试重新获取。这有助于防止死锁,因为哲学家线程在释放了左边的叉子后,其他哲学家线程有机会获取该叉子。
都是用于实现并发的概念,但它们在执行方式和调度上有一些不同
关系和执行流程:
线程内创建协程: 一个线程内部可以创建多个协程,而这些协程共享同一个线程的执行上下文。协程的切换是由程序员显式地进行的,而不是由操作系统调度。
协程执行到系统调用: 当协程执行到一个系统调用时,如果这个系统调用会导致阻塞,那么协程会被阻塞。在传统的同步阻塞调用中,整个线程会被阻塞,但在协程中,只有执行到这个系统调用的协程会被阻塞,其他协程仍然可以执行。
协程切换: 当一个协程被阻塞时,程序员可以选择切换到其他未阻塞的协程执行。这种协程之间的切换是非常灵活的,而不像线程切换那样受限于操作系统的调度。
协程的上下文切换:
由程序员控制: 协程的上下文切换是由程序员显式控制的,通常在代码中通过特定的操作(如
yield
)来触发切换。保存上下文: 当协程主动让出执行权时,它的当前状态(包括局部变量、程序计数器等)会被保存,通常保存在协程对象中。
切换到另一个协程: 程序员可以选择切换到另一个协程,恢复其之前保存的状态,继续执行。
非抢占式: 协程的切换通常是非抢占式的,也就是说只有当协程自愿让出执行权时才会发生切换。这与线程的抢占式调度不同,线程可以在任何时刻被调度器中断。
无需操作系统支持: 与线程不同,协程的上下文切换不依赖于硬件或操作系统的特殊支持。它是在用户空间中实现的,通常由编程语言或库提供支持。
第一次接触到协程是在学习GO的语法的时候,当时只是学习了怎么去用,但是原理并不了解,这里JYY老师也介绍到了。
当一个协程执行到系统调用(如write())时,会导致整个线程block,而GO面对这种情况有特殊的处理机制。
在Go语言中,当一个协程(goroutine)执行到一个可能会阻塞的系统调用时,Go运行时系统会采用一种称为"m-p-g模型"(m: machine, p: processor, g: goroutine)的机制,而不是简单地将整个协程阻塞。这个模型允许 Go 运行时系统在协程阻塞时不阻塞整个线程,而是将线程切换到其他可运行的协程,以保持并发性。
具体而言,当一个协程调用可能会阻塞的系统调用时,Go运行时系统会创建一个新的系统线程(称为
m
),并在这个线程上执行协程。这个m
线程与调用系统调用的协程关联起来,而其他协程仍然可以在其他m
线程上继续执行。这个机制的好处在于,当一个协程被系统调用阻塞时,不会影响整个 Go 程序的并发性能。其他协程可以继续在不同的线程上执行,从而充分利用多核处理器。
这也意味着在 Go 中,很多系统调用是非阻塞的版本。例如,Go 的网络和文件 I/O 函数通常会使用非阻塞的方式工作,以允许在一个线程上执行多个协程,而不被阻塞的协程会在系统调用时转移到其他线程上执行。
GO协程之间通信可以用channel,而不是共享内存,这就解决了很多并发的问题。
锁的顺序是通过强制执行一定的获取锁的顺序,以减少死锁的概率。死锁发生的典型情况是,多个线程分别获取一些锁,然后等待其他线程释放它们所需要的锁,导致所有线程都无法继续执行。通过定义锁的获取顺序,可以有效地预防这种情况。
考虑以下的场景,其中有两个锁 lock1
和 lock2
:
lock1 = new Lock()
lock2 = new Lock()
然后有两个线程 A 和 B,它们的执行顺序如下:
线程 A:
lock1.acquire()
lock2.acquire()
# 执行操作
lock2.release()
lock1.release()
线程 B:
lock2.acquire()
lock1.acquire()
# 执行操作
lock1.release()
lock2.release()
不按照锁顺序的问题:
如果线程 A 和线程 B 按照不同的顺序获取锁,那么就有可能发生死锁的情况。考虑以下情况:
lock1
。lock2
。lock2
,但它被线程 B 持有。lock1
,但它被线程 A 持有。在这种情况下,线程 A 和线程 B 会相互等待对方释放锁,导致死锁。
按照锁顺序的解决方案:
通过规定锁的获取顺序,例如规定按照锁的编号升序获取,我们可以避免上述死锁情况。例如:
# 规定锁的顺序
def threadA():
lock1.acquire()
lock2.acquire()
# 执行操作
lock2.release()
lock1.release()
# 规定锁的顺序
def threadB():
lock1.acquire()
lock2.acquire()
# 执行操作
lock2.release()
lock1.release()
这样,线程 A 和线程 B 都按照相同的锁获取顺序执行,它们都会按照 lock1
先后顺序获取锁,然后按照 lock2
先后顺序获取锁。这就避免了死锁的可能性。
对于死锁和数据竞争问题有调试工具
reset开始
GDB的插件可以支持反向调试和执行重放,一些第三方的插件和工具可以增加这些功能
rr(Record and Replay): rr 是一个用于记录和重放执行的工具,它可以与 GDB 集成。通过使用 rr,你可以记录程序的执行,然后在需要时重放执行,以便进行反向调试。rr 的项目主页:rr-project.org
Rekall: Rekall 是一个强大的开源内存分析框架,可以用于反向调试、执行重放以及进行其他与内存相关的分析。它提供了一个 Python 接口,可以与 GDB 集成。Rekall 项目主页:github.com/google/rekall
计算机开机时,首先由计算机的可扩展固件接口(UEFI)进行初始化,UEFI加载引导加载程序(Boot Loader)到内存中,引导加载程序LILO(LInux LOader)加载操作系统的内核,内核负责初始化系统硬件,启动用户空间的第一个进程,该进程是init进程,init
进程可以通过 exec
系统调用动态地启动其他程序,以实现系统初始化和服务管理的功能。再之后操作系统就会变成一个中断/异常处理程序。
状态机模型
整个世界都是init进程通过各种各样的系统调用创建出来的
创建一个一模一样的进程,内存每个字节都一样,寄存器一样,只有pid不同
fork()
函数的返回值也不同:
在父进程中:
fork()
返回子进程的PID(进程标识符),这个值是一个正整数,表示新创建子进程的PID。在子进程中:
fork()
返回0,表示当前处于子进程的执行环境。这个机制允许程序在 fork()
调用后通过返回值来判断当前是在父进程还是子进程中,从而在两个进程中执行不同的逻辑。根据 fork()
的返回值采取不同的操作,例如:
pid_t child_pid = fork();
if (child_pid == 0) {
// 子进程执行的代码
// ...
} else if (child_pid > 0) {
// 父进程执行的代码
// ...
} else {
// fork() 失败的处理
// ...
}
这样的代码结构允许在父子进程中分别执行适当的操作。fork()
的返回值为负值表示 fork()
失败,可能是由于系统资源不足等原因。
fock()之后,操作系统的执行模型就变成了并发程序,和我们多线程程序一样,就可以选一个状态机来执行一步(进程一or进程二)
重置一个状态机,把状态机重置成某一个程序的初始状态
int execve(const char *pathname, char *const argv[], char *const envp[]);
pathname
参数是要执行的程序文件的路径。argv
参数是一个字符串数组,包含了新程序的命令行参数。数组的第一个元素通常是程序的名称,后续元素是命令行参数。envp
参数是一个字符串数组,包含了新程序的环境变量。这个数组以 NULL
结尾。execve
的作用是用指定的程序替换当前进程的映像,即将当前进程的地址空间、文件描述符等全部替换为新程序的。这个系统调用在创建新进程时经常与 fork
配合使用,先通过 fork
创建一个子进程,然后在子进程中使用 execve
来执行新程序。
调用成功时,execve
并不返回,而是将新程序的控制权交给了新的进程映像。如果调用失败,它将返回-1,并设置 errno
来指示错误的原因。
环境变量与 execve
的关系:
在Unix系统中,环境变量是一种在进程间传递配置信息的常用机制。execve
在执行新程序时,除了传递程序的路径外,还会传递环境变量。环境变量是由键值对组成的字符串数组,用于设置新程序执行的环境。
环境变量中包含了一些重要的系统配置信息,如 PATH
变量,它指定了系统在哪些目录中查找可执行文件。当 execve
执行时,系统会使用新程序的环境变量,这也是为什么在 PATH
中包含 bin
目录的原因。
系统会按照一定的规则在环境变量 PATH
中指定的路径中搜索。由于常见的系统命令和工具通常位于 bin
目录中,所以在使用 execve
启动这些命令时,需要确保 bin
目录在 PATH
环境变量中。这样系统就能够在 bin
目录中找到相应的可执行文件。
为什么管道会出现8个hello?
输出到终端是linebuf,遇到/n会刷写缓冲区;输出到pipe和文件是fullbuf,缓冲区满才会刷写。所以我们开始输出到终端时,带着/n,直接就是刷写到缓冲区,不会被fork复制,因此输出了6个;而输出到管道或者文件,因为缓冲区没满,所以不会被刷写,而缓冲区会被fork()复制,所以就出现了8个。比如:i=0,pid=3,buf x1,经过一次fork就会变成, i=1,pid=3,buf x1, i=1,pid=4,buf x1,而第二次循环又有printf,所以上面的每个buf数量都为2,再经过一次fork,变成有4个buf x2 的进程,所以输出8.
exit()
是C标准库中的函数 ,只关闭当前线程,并在正常退出时刷新缓冲区。
_exit()
是系统调用,直接终止进程而不进行清理工作。不会刷新缓冲区。
一个程序的地址空间可以分为以下几个部分:
代码段(Text Segment):
数据段(Data Segment):
堆(Heap):
栈(Stack):
VDSO(Virtual Dynamic Shared Object)是一个用于优化系统调用性能的机制,它允许用户空间的程序直接执行一些系统调用,而无需陷入内核态。
VDSO 主要有两个方面的作用:
提供高效的系统调用: VDSO 中包含一些常用的系统调用,例如获取当前时间、获取系统调用编号等。通过在用户空间执行这些系统调用,避免了进入内核态的开销,从而提高了性能。
无需进入内核的系统调用: 对于一些特定的系统调用,例如获取当前时间,VDSO 提供了用户空间执行的实现,无需进入内核。这样可以减少用户空间和内核空间之间的切换次数,提高效率。
在 Linux 系统中,VDSO 是通过一个特殊的共享库(vdso.so)实现的。当程序运行时,内核会将 VDSO 映射到用户空间,使得程序可以直接调用其中的函数,而无需陷入内核。这对于一些频繁执行的系统调用,如获取时间戳,能够提供显著的性能优势。
需要注意的是,并非所有的系统调用都可以通过 VDSO 来完成,一些需要内核执行的系统调用仍然需要进入内核态。 VDSO 主要是为一些性能敏感的系统调用提供了一种高效的执行方式。
mmap
是一个用于将文件或者设备映射到进程地址空间的系统调用。
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
addr
:映射区域的首地址,通常设置为0,由系统自动选择。length
:映射区域的长度(字节)。prot
:映射区域的保护方式,指定页面可以读、写、执行等权限。flags
:映射区域的标志,例如是映射文件、映射匿名内存等。fd
:文件描述符,如果是映射文件的话。offset
:文件映射的偏移量。mmap
返回映射区域的起始地址。如果映射失败,返回 MAP_FAILED
(通常为 (void*) -1
)。
简单例子
mmap
用于将文件 "example.txt" 映射到内存,然后程序打印文件的内容
#include
#include
#include
#include
#include
int main() {
int fd = open("example.txt", O_RDONLY);
if (fd == -1) {
perror("open");
exit(EXIT_FAILURE);
}
// 获取文件大小
struct stat file_stat;
if (fstat(fd, &file_stat) == -1) {
perror("fstat");
exit(EXIT_FAILURE);
}
// 使用 mmap 将文件映射到内存
void *mapped = mmap(NULL, file_stat.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
if (mapped == MAP_FAILED) {
perror("mmap");
exit(EXIT_FAILURE);
}
// 打印文件内容
printf("File Content:\n%s\n", (char*)mapped);
// 释放映射区域
if (munmap(mapped, file_stat.st_size) == -1) {
perror("munmap");
exit(EXIT_FAILURE);
}
// 关闭文件描述符
if (close(fd) == -1) {
perror("close");
exit(EXIT_FAILURE);
}
return 0;
}
修改别的进程的内存
获取目标进程的 PID:
- 通过一些手段,比如查看系统进程列表、使用特定工具(ps、top等)等方法获取到目标进程的 PID。
使用
/proc/[PID]/mem
文件:
/proc/[PID]/mem
文件是一个伪文件,用于表示一个进程的内存映射。该文件对于读取是开放的,但对于写入是关闭的。把整个进程的内存作为一个文件暴露出来了打开并读取
/proc/[PID]/mem
文件:
- 使用
open()
系统调用打开这个文件。- 如果使用
ptrace
,不需要直接打开/proc/[PID]/mem
文件。而是通过ptrace
提供的接口读取和修改目标进程的内存。使用
pmap
获取内存映射信息:
pmap
是一个用于显示进程内存映射信息的工具,可以获取目标进程的内存布局,包括可读、可写、可执行等信息。- 使用
popen
来执行pmap -x [PID]
命令,然后从输出中分析每一段内存的信息。扫描内存,修改值:
- 遍历内存,找到可写的段,例如金钱是2000,遍历该内容,然后在游戏中修改为1700,再次遍历,就可以获得金钱的地址,从而修改。
Shell是一门"把用户指令翻译成系统调用"的编程语言,相当于给kernel套了个壳,可以与用户交互,所以可以理解为用户和计算机的接口。
它的执行流程就是不停的调用getcmd(),getcmd()通过read系统调用去读编号为0的文件描述符(标准输入),知道换行符('\n')为止,把内容读到buf中。
之后再通过buf的内容解析执行命令,每个命令都对应文件系统里的一个程序,shell就是要启动这些程序(cd除外,cd是内部命令,解析出命令为'cd'会执行一个系统调用,which cd会找不到文件,which 其他命令 就可以找到对应的二进制文件,一般都在bin目录下)
代码中会通过fock一个新进程来解析执行命令,因为这样就只需要分配内存而不用回收,进程被杀死的时候会自动回收内存
利用状态机分析
当你在终端中按下 Ctrl+C 时,通常会发送一个 SIGINT(Interrupt)信号到当前运行的进程,这会导致进程终止或响应中断信号的默认行为。
在终端中使用 kill
命令手动发送信号给进程,例如:
kill -SIGINT [进程ID]
这会模拟类似 Ctrl+C 的行为。
该命令会给当前前台进程组发送信号,如果fock子进程,那子进程也会被kill。
这题可以转换为以下形式
解题思路:我们就拿两个线程A和B来举例,线程A先执行,让线程A一直执行到循环末尾,此时A再执行一步就结束了,我们让A停住,把B执行一步,然后B会把sun=1的结果写入全局变量,此时我们再执行A,A就读到sum=1,然后执行sum++,现在A线程的sum是2了,在这里停住,我们此时再把B线程跑完,然后最后执行A线程把sum=2的值写入全局变量,现在所有线程执行完毕,sum的结果是2。
原本一个进程持有一个操作系统的对象(文件描述符),fock新创建的进程同样持有指向同一对象的文件描述符,相当于两个指针指向同一对象。
execve同样会继承文件描述符。
使用系统调用 open() 打开文件时,会遇到一些标志参数,其中包括 O_APPEND
和 O_CLOEXEC
。这些标志是通过按位或运算(|
)添加到 open 函数的第二个参数中的。
O_APPEND:
O_CLOEXEC:
exec
系统调用(例如 execve
)时自动关闭文件描述符。这对于在执行新程序时避免文件描述符泄漏非常有用。打开一个文件描述符会带有一个偏移量offset,它指示下一次读取或写入文件时的位置。
使用 dup
或 dup2
复制文件描述符时,新创建的文件描述符将与原始文件描述符共享相同的文件表项和文件偏移量,如果在一个文件描述符上调用 lseek
来更改文件偏移量,那么另一个文件描述符的文件偏移量也会相应地改变。
Copy-on-write
当一个进程 fork 一个子进程时,子进程会继承父进程的地址空间。这包括代码段、数据段、堆和栈等。在初始时,父子进程共享相同的物理内存页。
对于只读的数据,比如代码段,父子进程可以共享同一物理页,因为这些数据是不可修改的。只有当一个进程尝试写入这些只读的数据时,才会导致页的分离。这时,内核会为进程创建一个新的物理页,以确保写入操作不会影响到其他进程。
对于可写数据,比如堆和栈,父子进程最初共享同一物理页。只有当一个进程(父进程或子进程)尝试写入这些可写数据时,才会导致页的分离。此时,内核会为进行写入操作的进程创建一个新的物理页,以确保写入操作不会影响到其他进程。
当一个进程到达某个状态时,可以fork出多个子进程,每个子进程在不同的方向上进行探索。如果其中一个子进程找到了解决方案,那么整个搜索可以立即终止,就可以直接kill该进程。这样,每个进程都负责探索不同的路径,而不需要回溯。
JVM也用到类似的思想,虚拟机在初始化时确实会加载一些基础类库,比如java.lang.Object
、java.lang.String
等,这些类是Java语言的核心部分,几乎所有的Java程序都会用到。这些类的加载确实只会发生一次,并在整个JVM生命周期内一直存在,被所有的类共享。
在某些情况下,fork
并不是最佳的选择,尤其是在现代并发编程的环境中。原因:
复杂性: 使用 fork
创建新进程后,父子进程之间会共享相同的内存空间,这可能导致复杂的共享状态和同步问题。需要小心处理父子进程之间的通信和同步。
性能开销: 创建新进程通常涉及复制父进程的地址空间,这会带来较大的性能开销,特别是对于大型应用程序而言。相比之下,线程的创建和上下文切换成本较低。
资源使用: 进程是相对重量级的资源,与线程相比,它们占用更多的系统资源。在某些情况下,创建许多进程可能会导致系统资源的过度消耗。
可执行文件是描述进程初始状态的数据结构。
运行可执行文件一般的系统调用流程:
1.用户双击可执行文件 a.out
,或者通过命令行运行该程序。
2.调用fork
系统调用来创建一个新的进程。
3.子进程调用execve来加载a.out
的内容到新的进程的地址空间。
4.子进程开始执行程序的入口点,通常是main
函数。
a.c
)加上可执行权限并直接执行execve为什么不会成功?
此时execve会返回-1,根据手册会给出失败信息,在没有编译的情况下直接执行C源文件会导致解释错误。
#!
(shebang)行为什么又可以执行a.c了?加上#!
(shebang)行是为了告诉系统应该使用哪个解释器来执行这个脚本或程序。可以偷梁换柱,替换传入execve的参数
例如,如果在a.c
的第一行加上:
#!/usr/bin/python3
执行./a.c
就相当于在命令行中输入/usr/bin/python3 a.c
假设分别有main.c和hello.c
编译和链接的过程
编译: 编译每个源文件(main.c 和 hello.c)生成对应的目标文件(main.o 和 hello.o)。
链接: 在链接阶段,链接器将解析符号(函数、变量等)并分配实际的地址。此时,被调用函数的地址(S)和调用指令的位置(P)都已知。计算偏移量(A = S - P)并将其添加到调用指令中。
生成可执行文件: 最终,链接器将合并所有的目标文件,并生成最终的可执行文件。
在main.S中call指令在没有链接的时候对应的位置是未知的
链接之后会满足以下要求
这里解释一下 "S+A-P" 的概念:
S(Symbol): 是被调用的函数(例如,hello
)的符号。这个符号包含了函数的地址信息。
A(Addend): 是一个与符号相关的偏移量,用于调整地址。
P(Place): 是调用指令(比如call
)的位置。在这里距离函数开头 0xb 字节的位置。这个位置是调用指令的位置,也是 "P" 的值。e8 00 00 00 00的首个00。
所以, "S+A-P" 表示调用指令的地址是通过被调用函数的地址(S)加上一个偏移量(A),再减去调用指令的位置(P)来计算得到的。
当在使用 VSCode 打开一个项目时,VSCode 会尝试解析项目中的源代码,并提供代码提示、自动补全等功能。为了更好地理解代码结构,VSCode 需要了解项目的编译配置,包括头文件路径、编译器参数等信息。
在项目中,通过在 makefile
中添加 include
参数,告诉编译器在编译过程中应该包含的头文件路径。这样在编译的时候,编译器知道去哪里找头文件,但是在打开 VSCode 时,VSCode 还不知道这些信息。
为了让 VSCode 理解项目的编译配置,可以使用 bear
工具。bear
会监视 make
命令的执行,捕获编译器的调用和参数,并生成一个名为 compile_commands.json
的文件。这个文件包含了编译每个源文件时使用的编译器命令及其参数。
当通过命令行执行 bear make qemu
后,bear
生成了这个 compile_commands.json
文件。VSCode 可以读取这个文件,从而了解头文件路径等编译信息,提供更准确的代码智能感知。
这里推荐去看mit6s081 通过vscode来debug kernel_哔哩哔哩_bilibili
简而言之就是配置 launch.json 和 .gdbinit
launch.json
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug xv6",
"type": "cppdbg",
"request": "launch",
"program": "${workspaceFolder}/kernel/kernel", // 可执行文件路径
"args": [],
"stopAtEntry": true,
"cwd": "${workspaceFolder}",
"miDebuggerServerAddress" : "localhost:26000" ,
"miDebuggerPath" : "/usr/bin/gdb-multiarch" ,
"environment": [],
"externalConsole": false,
"MIMode": "gdb",
"setupCommands": [
{
"description": "Enable pretty-printing for gdb",
"text": "-enable-pretty-printing",
"ignoreFailures": true
}
],
"logging": {
"engineLogging":true,
"programOutput": true,
},
"preLaunchTask": "make-gdb",
}
]
}
.gdbinit
set confirm off
set architecture riscv:rv64
#target remote 127.0.0.1:1234
symbol-file kernel/kernel
set disassemble-next-line auto
set riscv use-compressed-breakpoints yes
运行debug后就相当于在命令行写入
launch.json的"miDebuggerPath"字段 "program"字段 运行
/usr/bin/gdb-multiarch kernel/kernel
然后再写入.gdbinit 的命令并运行
然后就会跳到kernel的main函数入口
在 xv6 操作系统中,trapframe
结构用于保存进程的寄存器状态,而进程的内存状态则通过页表(Page Table)来保存。
trapframe
结构:
trapframe
是一个结构体,它保存了进程在发生中断或异常时的寄存器状态。trapframe
被保存,而切换到新进程时,新进程的 trapframe
被加载,以恢复寄存器状态。trapframe
包含各个寄存器的值。页表:
上下文切换过程:
trapframe
,将新进程的 trapframe
装入相应寄存器。Round-Robin 策略保持公平性,每个进程都有相等的机会获得 CPU 时间。所有就绪进程按照队列的顺序轮流执行,没有特定的优先级。
工作原理:
新进程入队:
调度执行:
动态调整优先级:
时间片用尽:
等待时间过长:
使用了虚拟运行时间的概念,虚拟运行时间取决于实际运行时间和它的"nice"值。
CFS 使用以下公式计算进程的时间片: 时间片 = 物理时间 / 权重值
也就是说经过相同的物理时间,权重值大(优先级高)的进程计算出的时间片少,那么按照公平的原则,CPU就会给它分配更多的时间。比如vim和一个死循环进程,vim的权重值远高于死循环进程,那么物理时间经过1ms,vim的时间片就是1ms,而死循环进程的时间片是100ms(实际它只经过1ms),CPU就会因为vim的时间片少而分配更多的资源给vim。
当父进程fock的时候,子进程会继承父进程的virtual runtime,父进程继续执行。
某种程度上来说,这是一种好的设计。因为在内核中的代码的数量较小,更少的代码意味着更少的Bug。
但是这种设计也有相应的问题。假设我们需要让Shell能与文件系统交互,比如Shell调用了exec,必须有种方式可以接入到文件系统中。通常来说,这里工作的方式是,Shell会通过内核中的IPC系统发送一条消息,内核会查看这条消息并发现这是给文件系统的消息,之后内核会把消息发送给文件系统。
文件系统会完成它的工作之后会向IPC系统发送回一条消息说,这是你的exec系统调用的结果,之后IPC系统再将这条消息发送给Shell。
所以,这里是典型的通过消息来实现传统的系统调用。现在,对于任何文件系统的交互,都需要分别完成2次用户空间<->内核空间的跳转。与宏内核对比,在宏内核中如果一个应用程序需要与文件系统交互,只需要完成1次用户空间<->内核空间的跳转,所以微内核的的跳转是宏内核的两倍。通常微内核的挑战在于性能更差,这里有两个方面需要考虑:
DRAM(Dynamic Random Access Memory)和SRAM(Static Random Access Memory)都是计算机系统中常见的内存类型,它们在结构和工作原理上有一些显著的区别。
存储单元的构造:
访问速度:
稳定性和功耗:
集成度和成本:
应用领域:
考虑一个简单的字符设备驱动程序,比如一个虚拟的字符设备,通过 /dev
文件系统提供用户空间应用程序与内核之间的通信。
#include
#include
#include
#define DEVICE_NAME "mychardev"
#define BUF_SIZE 1024
static char device_buffer[BUF_SIZE];
static int major_number;
static int device_open_count = 0;
static int device_open(struct inode *inode, struct file *file) {
if (device_open_count)
return -EBUSY;
device_open_count++;
return 0;
}
static int device_release(struct inode *inode, struct file *file) {
device_open_count--;
return 0;
}
static ssize_t device_read(struct file *file, char __user *buffer, size_t length, loff_t *offset) {
// 从设备缓冲区读取数据到用户空间
// 省略错误检查和其他细节
copy_to_user(buffer, device_buffer, length);
return length;
}
static ssize_t device_write(struct file *file, const char __user *buffer, size_t length, loff_t *offset) {
// 将用户空间数据写入设备缓冲区
// 省略错误检查和其他细节
copy_from_user(device_buffer, buffer, length);
return length;
}
static struct file_operations fops = {
.open = device_open,
.release = device_release,
.read = device_read,
.write = device_write,
};
static int __init chardev_init(void) {
// 注册字符设备驱动程序
major_number = register_chrdev(0, DEVICE_NAME, &fops);
if (major_number < 0) {
printk(KERN_ALERT "Failed to register a major number\n");
return major_number;
}
printk(KERN_INFO "Registered correctly with major number %d\n", major_number);
return 0;
}
static void __exit chardev_exit(void) {
// 卸载字符设备驱动程序
unregister_chrdev(major_number, DEVICE_NAME);
printk(KERN_INFO "Unregistered character device\n");
}
module_init(chardev_init);
module_exit(chardev_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple character device driver");
这个例子中,代码中包含了一些 Linux 内核开发的头文件,定义了一个简单的字符设备驱动程序,包括打开、关闭、读取和写入操作。它通过register_chrdev
函数注册到内核,并通过unregister_chrdev
函数卸载。
SIMT(Single Instruction, Multiple Threads)是一种并行计算模型,主要用于描述 GPU(图形处理器)中的线程执行方式。在 SIMT 模型中,多个线程同时执行相同的指令,但这些线程处理的是不同的数据。
CUDA(Compute Unified Device Architecture)是 NVIDIA 开发的一种并行计算平台和编程模型,用于利用 NVIDIA GPU(图形处理器)的计算能力进行通用目的的并行计算。CUDA 允许程序员使用一种类似于 C 语言的编程语言编写并行代码,并通过 NVIDIA 的 GPU 运行这些代码。
磁盘的读写操作通常是以块(block)为单位进行的。一次读写一个块相较于一个字节或一个扇区来说,能够更高效地利用磁盘的带宽。
挂载是将一个文件系统与指定的目录(挂载点)关联起来的过程。这个操作使得文件系统中的文件和目录可以通过挂载点在整个文件系统层次结构中访问。使用 mount
命令将文件系统挂载到指定的挂载点。例如当你将 U 盘插入计算机并将其挂载到指定的挂载点时,U 盘中的文件系统就会在该挂载点下可用,你就可以通过该挂载点访问 U 盘中的文件和目录了。
在多个进程中调用 printf
这样的标准输出函数时,是通过操作系统的机制来保证输出的原子性。操作系统内核是负责调度进程执行的实体。当一个进程调用 printf
时,内核可能会将该进程标记为不可抢占状态,即在 printf
操作完成之前不会被其他进程中断。
定义: 硬链接是文件系统中一个文件的多个名称指向相同的存储空间。换句话说,多个文件名指向同一个 inode。
文件系统支持: 硬链接只能在同一个文件系统内创建。
对原文件的影响: 如果你删除原始文件,硬链接仍然可以访问数据,因为数据仅仅是 inode 中的链接计数减少,而不是真正删除。只有当链接计数降为零时,文件的存储空间才会被释放。
inode 和硬链接数量: 一个 inode 可以有多个硬链接,每个硬链接都增加了 inode 的链接计数。
定义: 软链接是一个指向另一个文件或目录的链接,它包含了目标文件的路径。软连接类似于 Windows 系统中的快捷方式。
跨文件系统支持: 软链接可以跨越文件系统,因为它们只是包含了目标路径的文本信息。
对原文件的影响: 如果原始文件被删除,软链接将变得失效(称为悬空指针),因为它只是一个指向路径的引用。软连接本身占用一小块存储空间。
inode 和软链接数量: 软链接和目标文件有不同的 inode,因此软链接的创建不会影响目标文件的链接计数。
总结:
使用硬链接时,多个文件名实际上指向相同的数据块,因此删除其中一个硬链接并不会影响其他硬链接。
使用软链接时,文件名指向一个包含目标路径的特殊文件,如果目标文件被删除,软链接将失效。
硬链接通常用于提供文件的多个访问点,而软链接用于创建更灵活的文件指向关系。
磁盘提供的接口是block read和block write,往上抽象成bmalloc/bfree,最后抽象成文件系统的接口。
设计一的缺陷无法解决,若要读磁盘上的最后一块的数据,需要把整个磁盘的数据都读一遍。设计二的缺陷可以用备份来解决,因此FAT文件系统采用设计二的思想。
FAT(File Allocation Table,文件分配表)主要作用是记录磁盘上每个簇(cluster)的分配情况,其中一个簇是文件系统中的最小存储单元,通常包含多个扇区。以下是 FAT 表的关键特点:
簇的分配情况: FAT 表中的每个表项对应于一个簇,表示该簇的分配状态。常见的状态包括空闲、已分配给文件,或者标记为坏簇。
簇链: 文件在存储介质上是以簇的形式存储的,FAT 表通过链表的方式记录了每个文件的簇链。每个簇的表项包含指向下一个簇的指针,形成一个链表结构。
文件分配: 文件在 FAT 文件系统中由多个簇组成,FAT 表记录了这些簇的分配情况,文件的簇链是通过这些表项连接起来的。
两个 FAT 表: 为提高容错性,通常会存在两个 FAT 表,称为 FAT1 和 FAT2。如果一个 FAT 表损坏,系统可以尝试从另一个表中恢复信息。
读手册可以用这种把左边的东西"copy"到右边的思想
Inode Block(索引节点块):
Superblock Block(超级块块):
总的来说,inode block 存储文件级别的元数据,而 superblock block 存储整个文件系统的元数据。
RAID 0(条带化):
- 原理: 数据被分成块,并跨多个硬盘驱动器条带化存储。
- 设计空间: 提高性能,但没有冗余。如果一个硬盘故障,所有数据都可能丢失。
RAID 1(镜像):
- 原理: 数据被复制到两个硬盘,形成镜像。
- 设计空间: 提供冗余,但没有性能提升。可以容忍一个硬盘故障,但需要额外的硬盘空间。
RAID 10(1+0):
1.原理: 将RAID 1(镜像)和RAID 0(条带化)结合,提供冗余和性能。 2.设计空间: 可以容忍一个或多个硬盘故障,具有良好的读取和写入性能。需要更多的硬盘空间。如下图(抽象过程从上往下来看,读写过程从下往上来看),即可实现快速读写,又能实现可靠性(有副本),不过会耗费容量,且只能容忍两块磁盘损坏,这两块磁盘不能同时为下图的AB盘。
RAID 4:
数据分布:
- RAID 4 将数据分成块,然后将这些块分布在各个硬盘上,类似于 RAID 0 的条带化。
- 但是,有一个硬盘专门用于存储奇偶校验信息,称为奇偶盘。奇偶校验信息用于容忍硬盘故障。
性能:
- 读取性能较高,因为数据可以从多个硬盘并行读取。
- 写入性能相对较差,因为每次写入需要更新奇偶校验信息。
容忍硬盘故障:
- 可以容忍一个硬盘故障,因为奇偶校验信息允许在其中一个硬盘失败时重新构建数据。
RAID 5:
数据分布:
- RAID 5 也将数据分成块,并将这些块分布在各个硬盘上,类似于 RAID 0 的条带化。
- 与 RAID 4 不同,RAID 5 在所有硬盘上均匀分布奇偶校验信息,而不是使用一个专门的奇偶盘。每个数据块的奇偶校验信息存储在其他硬盘上。
性能:
- 读取性能较高,因为数据也可以从多个硬盘并行读取。
- 写入性能相对较好,因为奇偶校验信息分布在各个硬盘上,减轻了单一瓶颈。
容忍硬盘故障:
- 可以容忍一个硬盘故障,因为奇偶校验信息允许在其中一个硬盘失败时重新构建数据。
CAP 代表 Consistency(一致性)、Availability(可用性)和 Partition Tolerance(分区容忍性),它指出在一个分布式系统中,不可能同时满足这三个属性,只能选择其中的两个。
以下是 CAP 理论的三个要素:
一致性(Consistency): 所有节点在同一时间具有相同的数据视图。换句话说,在分布式系统中的所有节点都能够看到相同的数据状态,无论进行读取操作的节点是哪一个。
可用性(Availability): 每个非故障节点在请求时都能够返回有效的响应,无论系统中的其他节点是否故障。可用性强调系统对读写请求的实时响应能力。
分区容忍性(Partition Tolerance): 系统能够继续工作,即使网络中的节点之间发生了分区(网络分割)。分区容忍性是指系统在面对网络故障或节点之间通信失败时,仍然能够正常运行。
根据 CAP 理论,一个分布式系统无法同时满足这三个属性,只能在一致性、可用性和分区容忍性中选择两个。这就意味着在设计分布式系统时,必须在这三个属性之间进行权衡,并根据实际需求选择合适的权衡方案。
Raft 算法的目标是解决分布式系统中节点之间如何达成一致性的问题,例如在分布式数据库、分布式文件系统等场景中。在 Raft 中,节点通过选举的方式选择一个领导者,领导者负责提出新的日志条目,而其他节点则负责复制这些日志,从而达成一致的状态。
以下是 Raft 算法的关键特点和概念:
领导者选举: Raft 使用一种称为 "leader election" 的机制,确保系统中只有一个领导者。在 Raft 中,任何时刻都有三种节点状态:领导者、跟随者和候选者。如果一个跟随者在一段时间内没有收到领导者的消息,它可以转变为候选者,并尝试发起选举。
日志复制: 领导者负责提出新的日志条目,其他节点则复制这些日志条目以保持一致性。Raft 确保只有领导者的日志是正确的,其他节点通过复制领导者的日志来保持一致性。
一致性检查点: Raft 引入了一致性检查点的概念,以便在节点故障后加速恢复。节点可以保存在某个点之前的所有日志条目,从而在需要时通过加载检查点来减少重新复制的工作量。
分区容忍性: Raft 具有良好的分区容忍性,即使网络分区发生,Raft 仍然能够正常工作。在发生网络分区时,可能会有多个领导者产生,但一旦分区恢复,节点将会选举出唯一的领导者。
LSM
LSM(Log-Structured Merge)是一种用于设计高性能持久化存储系统的数据结构和算法。它通常用于实现分布式数据库、键值存储引擎等系统,以提供高吞吐量、低写放大(write amplification)和低读放大的特性。
LSM 树的主要思想是将写操作和读操作分离,通过一系列的组件来优化写入性能。以下是 LSM 树的关键概念:
日志(Log): LSM 树将所有写入操作都记录在一个顺序写的日志中,这被称为 Write-Ahead Logging(WAL)。写入的数据首先追加到这个日志中,然后再异步地写入内存和磁盘。
内存组件(Memory Component): LSM 树维护一个位于内存中的组件,通常是一个有序数组或有序链表。新的写入首先追加到内存组件,以提供快速的写入性能。
磁盘组件(Disk Component): 当内存组件达到一定大小或时间间隔时,会将其转化为一个磁盘上的不可变的 SSTable(Sorted String Table)文件。这个文件通常包含了一段时间内的写入记录,并按顺序存储。
合并(Merge): 为了提高读取性能,LSM 树通过定期合并磁盘上的 SSTable 文件,生成新的较大的 SSTable 文件。这个过程被称为合并(merge)或压缩(compaction),目的是将多个小的 SSTable 合并为一个较大的 SSTable。
查询处理: 读取操作时,LSM 树会首先在内存组件中查找,然后在磁盘组件中查找。由于 SSTable 文件是有序的,可以通过二分查找等方法进行高效的查询。
LSM 树的优势在于其写入性能非常高,因为写入操作首先被追加到日志,然后异步地写入内存。读取性能也较好,因为查询可以通过顺序读取 SSTable 文件来进行。
最后一节课了,jyy yyds!
CS自学指南
翻看指南看到"梦开始的地方 —— CS61A"这一部分,回想到自己是在大二遇到了我觉得改变了我一生的导师帮我规划了CS学习路线(非常感谢他),于是开始学CS61A,很后悔没有在大一的时候明白这些道理, 导致浪费了很多时间。