服从感性抗拒理性
不愿活着心却死去
用赤裸去热情
不预留余地哦
——浪漫血液
完整代码见:SnowLegend-star/6.s081 at thread (github.com)
应该是有史以来最轻松的一个lab,大部分时间都花在了看实验说明给出的阅读材料和代码阅读上。真正着手完成代码的时间总共也就个把小时。上周过于摆烂,没怎么学进去。
Uthread: switching between threads (moderate)
主要就是看懂下面这段话,这才是真正的hint
您需要将代码添加到user/uthread.c中的thread_create()和thread_schedule(),以及user/uthread_switch.S中的thread_switch。一个目标是确保当thread_schedule()第一次运行给定线程时,该线程在自己的栈上执行传递给thread_create()的函数。另一个目标是确保thread_switch保存被切换线程的寄存器,恢复切换到线程的寄存器,并返回到后一个线程指令中最后停止的点。您必须决定保存/恢复寄存器的位置;修改struct thread以保存寄存器是一个很好的计划。您需要在thread_schedule中添加对thread_switch的调用;您可以将需要的任何参数传递给thread_switch,但目的是将线程从t切换到next_thread。
看完这段话,我第一反应是用switch进行context的切换。但看了一遍uthread.c后发现并没有相关context的定义啊,于是又开始纠结是不是自己思路有问题,但是如果没有context的话那保存线程的寄存器状态不是无稽之谈吗?考虑了许久遂去找了篇博客看,一看文章里面提到了自定义context。OK,思路是正确的。
// 创建thraed的上下文
struct context {
uint64 ra;
uint64 sp;
// callee-saved
uint64 s0;
uint64 s1;
uint64 s2;
uint64 s3;
uint64 s4;
uint64 s5;
uint64 s6;
uint64 s7;
uint64 s8;
uint64 s9;
uint64 s10;
uint64 s11;
};
switch.S直接模仿进程调度的swtch.c,直接粗暴搬过来用就行。
uthread_switch.S如下:
.globl thread_switch
thread_switch:
/* YOUR CODE HERE */
sd ra, 0(a0)
sd sp, 8(a0)
sd s0, 16(a0)
sd s1, 24(a0)
sd s2, 32(a0)
sd s3, 40(a0)
sd s4, 48(a0)
sd s5, 56(a0)
sd s6, 64(a0)
sd s7, 72(a0)
sd s8, 80(a0)
sd s9, 88(a0)
sd s10, 96(a0)
sd s11, 104(a0)
ld ra, 0(a1)
ld sp, 8(a1)
ld s0, 16(a1)
ld s1, 24(a1)
ld s2, 32(a1)
ld s3, 40(a1)
ld s4, 48(a1)
ld s5, 56(a1)
ld s6, 64(a1)
ld s7, 72(a1)
ld s8, 80(a1)
ld s9, 88(a1)
ld s10, 96(a1)
ld s11, 104(a1)
ret /* return to ra */
就是switch(uint64, uint64)和swtch(struct context*, struct context*)传参的不同让我有些费解。解决了thread_schedule()后,开始修改thread_create()。
也是阅读下proc.c内部的allocproc()就可以很容易得到思路,更具体点就是看126~129这三行。至于向thread_create()传入的“void (*func)()”这个参数该怎么用上呢?我们回顾下ra和sp的用法。
- ra:在函数调用过程中,调用指令会将返回地址存储在ra寄存器中,然后跳转到函数体开始执行。当函数执行完毕时,会将ra寄存器中的地址作为返回地址,用于返回到调用函数的下一条指令继续执行。
- sp:sp寄存器是栈指针寄存器,用于存储当前线程或函数的栈顶地址。在函数调用或线程切换过程中,栈指针会被更新,以便正确地分配和释放栈空间。当函数调用时,栈指针会向下移动以为新的函数调用分配栈空间;当函数返回时,栈指针会向上移动以释放栈空间,并将返回值弹出栈。在线程切换时,栈指针也会被更新为新线程的栈顶地址,以确保在新线程中正确执行栈操作。因此,sp寄存器在程序执行过程中维护着栈的正确状态,是实现函数调用和线程切换的关键之一。
为了让thread_create()执行完能顺利跳转到thread_a()继续执行,我们就把函数地址“func”存储到ra内部即可。sp则是在栈地址的基础上加上栈大小。
thread_create()
// YOUR CODE HERE
//执行完create函数后通过ra跳转到func继续执行
memset(&t->context, 0, sizeof(t->context));
t->context.ra=(uint64)func;
t->context.sp=(uint64)t->stack+STACK_SIZE;
Using threads(moderate)
这个part的主旨就是让我们利用线程的并发处理来增加吞吐率。这就体现出完成《CSAPP》的优点了,我已经完成过类似的lab,再来完成这个lab自然就得心应手。花了些时间阅读完ph.c后,我们可以发现产生race的根源就在于put_thread()的调用,更确切地说是对put的调用。下面是GPT对产生race现象的具体解释:
put_thread 函数会被两个线程同时执行。这意味着两个线程可能会同时访问同一个哈希桶,并尝试同时向其中插入元素,从而导致竞争条件(race condition)的发生。
具体来说,当两个线程同时执行 put_thread 函数时,它们都会计算出相同的哈希桶索引 i。然后它们都会尝试访问 table[i],并在该桶上进行插入操作。由于没有对哈希桶的访问进行同步,这可能导致以下问题:
同时写入:两个线程同时尝试向同一个桶中写入数据,这可能导致数据丢失或损坏,因为它们可能会覆盖彼此的写入。
非原子操作:即使两个线程尝试写入不同的桶,如果 insert 操作不是原子的,也可能导致竞争条件。在执行 insert 操作时,如果其中一个线程已经修改了链表的结构,而另一个线程也在修改同一个链表,那么这可能导致链表结构损坏,甚至导致内存泄漏或段错误等问题。
在这里我们只需要考虑同时写入就可以了。初步想法是给put()加一把大锁看看效果,发现这样效率果然不高。借助以下这句hint,解决方案就呼之欲出了:
在某些情况下,并发put()在哈希表中读取或写入的内存中没有重叠,因此不需要锁来相互保护。您能否更改ph.c以利用这种情况为某些put()获得并行加速?提示:每个散列桶加一个锁怎么样?
最后通过一把大锁来把i锁住,防止race导致i被覆盖。再用小锁把每个散列桶给锁住,映射到同一个桶的<key, value>不能同时写入,但是映射到不同桶的<key, value>可以同时写入。估计十分钟就把代码完成了,这种流程的感觉太好了。
这里就给出put()的实现
static
void put(int key, int value)
{
pthread_mutex_lock(&lock);
//用大锁把i锁住,防止race导致i被覆盖
int i = key % NBUCKET;
pthread_mutex_lock(&bucket_lock[i]);
pthread_mutex_unlock(&lock);
//小锁把每个桶给锁住
// is the key already present?
struct entry *e = 0;
for (e = table[i]; e != 0; e = e->next) {
if (e->key == key)
break;
}
if(e){ //说明是break导致for循环终止的
// update the existing key.
e->value = value;
} else { //说明for循环是正常终止,即当前链表中原来不存在这个<key, value>
// the new is new.
insert(key, value, &table[i], table[i]);
}
pthread_mutex_unlock(&bucket_lock[i]);
}
Barrier(moderate)
先来看看barrier到底是什么:
当在并行计算中,多个线程或进程需要在某一点进行同步时,屏障就发挥作用。它是一种同步机制,确保所有线程或进程都达到了一个特定点后才能继续执行。具体而言,屏障强制执行以下操作:
等待: 当一个线程或进程到达屏障时,它会被阻塞,直到所有其他线程或进程也到达屏障。
同步: 一旦所有线程或进程都到达屏障,它们会被释放,并且可以继续执行下一阶段的任务。
屏障通常用于在并行程序中确保所有的计算单元都完成了某个任务或阶段,然后再继续执行下一步操作。例如,在一个多线程的程序中,如果一个线程需要等待其他所有线程都完成某个计算任务后才能进行下一步操作,那么可以使用屏障来实现这种同步。
屏障的一个常见用途是在并行计算中的迭代过程中进行同步,确保每个迭代步骤都在所有线程或进程完成后才能进行下一步。这有助于避免竞争条件和不确定性,确保结果的正确性和可靠性。
一言蔽之,就是让所有进程前进的步伐一致。除了理解barrier的工作机制,还要搞清楚
pthread_cond_wait()和pthread_cond_timedwait()到底是怎么使用的。还有分别与之配套的函数pthread_cond_signal()和pthread_cond_broadcast()。在这个lab我们用wait和signal这两个函数就可以解决问题了。
理清上述内容后,模仿Barrier (computer science) - 维基百科,自由的百科全书 --- Barrier (computer science) - Wikipedia里面的barrier就可以着手完成lab了。不过Wikipedia给出的代码读起来有点绕,建议配合GPT阅读。
初步完成barrier后,下面这个hint差点把我给绕进去了:
您必须处理这样的情况:一个线程在其他线程退出barrier之前进入了下一轮循环。特别是,您在前后两轮中重复使用bstate.nthread变量。确保在前一轮仍在使用bstate.nthread时,离开barrier并循环运行的线程不会增加bstate.nthread。
我以为会有这样一种情况:当所最后一个进程到达barrier后,就可以逐步释放进程了。同时nthread被重新置为0。假设有一个进程率先成功退出barrier并再次调用barrier(),那nthread
的值又会从0重新开始累加了。同时,只有刚才最后一个到达barrier的线程会进入
if(bstate.nthread==nthread)
调用pthread_cond_signal(),也就是说只能唤醒一个在等待的线程。那其他线程是怎么被唤醒的呢?
如果是while的话,尽管wakeup一次只能唤醒一个进程,但是唤醒完这个进程后,这个进程执行完会继续释放这把锁来让其他进程被唤醒。
barrier()实现如下:
static void
barrier()
{
// YOUR CODE HERE
//
// Block until all threads have called barrier() and
// then increment bstate.round.
//
pthread_mutex_lock(&bstate.barrier_mutex);
bstate.nthread++;
if(bstate.nthread==nthread){ //所有进程都进入了临界区
bstate.nthread=0;
bstate.round++;
//告诉所有等待的进程可以开始执行了
pthread_cond_signal(&bstate.barrier_cond);
}
else{ //不是所有进程都到达了临界区,进入同步等待状态
pthread_cond_wait(&bstate.barrier_cond, &bstate.barrier_mutex);
}
pthread_mutex_unlock(&bstate.barrier_mutex);
}