说着是 Bomb Lab,其实写了很多别的东西233,相当于总结一部分目前对 CSAPP 的感受

CSAPP

全称 Computer Systems: A Programmer's Perspective,机械工业出版社的翻译版书名是 《深入理解计算机系统》

CSAPP 是一本讲述计算机系统的教材,广泛涵盖了计算机系统各自部分的主要主题,更为珍贵的是,它在保持广度惊人的情况下又有足够的深度,这从它的 Lab 就能看出来。

CSAPP 涵盖的主题包括:数据表示、程序的机器指令、存储系统、并发编程、文件 I/O、网络等等,第四章还讲述了 CPU 架构,差不多整本书就是想从最顶层的软件到最底层的电路开关都串起来。

这本书本身是从 CMU 的 15-213 ICS (Introduction to Computer Systems) 课程来的,相应的国内一些学校也会开设类似“系统编程“或是”计算机系统导论“的课,它们要么是直接讲 CSAPP 一部分章节要么就是根据 CSAPP 的核心内容作出更改。

ICS 的定位

不要被 15-213 的 Introduction 给骗了,CS 强校的导论课跟国内多数大学的水课导论完全不是一个概念。以 CSAPP 为基础的计算机系统导论课,可以说是我目前见过最难最折磨人的计算机课程。就连 Bomb Lab 自己也幽默地调侃了这一点:

...takes no responsibility for damage, frustration, insanity, bug-eyes, carpal-tunnel syndrome, loss of sleep, or other harm to the VICTIM...
...对受害者造成的任何损害、挫败感、精神失常、眼球突出、腕管综合征、失眠或其他伤害,加害者概不负责...

然而兴趣的力量大概足够抵消这些了,或许我的学校不开 ICS 课反而是一件幸事,使人有足够的动力去探索,而不是先让求知欲、耐心和精力被学校给消磨掉。

Bomb Lab

这可能是 CSAPP 里最出名的 Lab,具体内容是让学生通过 GDB 调试器去逆向分析一个已经编译好了的 Linux 二进制程序,得到 6 组拆除炸弹的正确密码。Bomb Lab 不仅锻炼学习者的工程能力,并且还会进一步建立学习者对程序语言、控制流、内存以及指令集架构的更深刻的洞见,这些都不能仅仅通过阅读教材来得到。

例如第一关的源码为:

/* Hmm...  Six phases must be more secure than one phase! */
   input = read_line();             /* Get input                   */
   phase_1(input);                  /* Run the phase               */
   phase_defused();                 /* Drat!  They figured it out!
			      * Let me know how they did it. */
   printf("Phase 1 defused. How about the next one?\n");

phase_1phase_defused 的实现当然是不可见的,整个 Lab 实际上就是要通过反汇编指令去倒推这类函数的实现,从而拆除炸弹

接下来就以第一关 phase_1 为例,再次回味一下 Bomb Lab。它虽然是最简单的一关,但是上手做一遍的收获也不小。

phase_1

在更改二进制炸弹的读写权限后,GDB!启动!

browniecake@MSI:~/csapp-lab/bomb$ gdb bomb

GNU gdb (Ubuntu 15.1-1ubuntu1~24.04.1) 15.1
Copyright (C) 2024 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Type "show copying" and "show warranty" for details.
This GDB was configured as "x86_64-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<https://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
    <http://www.gnu.org/software/gdb/documentation/>.

For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from bomb...

(gdb)

好的,目前我们需要的是做反汇编。在 GDB 中输入 layout asm,映入眼帘的是这个画面,一串 x86-64 Linux ABI 的指令:

disassembly

通过移动方向键可以一览整个炸弹的函数接口名称,我们往下翻就能翻到 <phase_1>,于是我们在那里利用 break phase_1 打个断点,接着 run 运行程序:

Welcome to my fiendish little bomb. You have 6 phases with
which to blow yourself up. Have a nice day!

好的,进入了这个令人心惊胆战的二进制炸弹了。接着输入 continue 在我们打的断点那里停下来:

phase_1

可以看到确实停在了 phase_1 的入口,我们接下来就可以仔细研究这段反汇编程序了:

sub    $0x8,%rsp
mov    $0x402400,%esi
callq  0x401338 <strings_not_equal>
test   %eax,%eax
je     0x400ef7 <phase_1+23>
callq  0x40143a <explode_bomb>
add    $0x8,%rsp
ret

栈指针

sub $0x8,%rsp 就是把栈指针(Register Stack Pointer,寄存器 rsp)减 8,给局部变量开辟栈空间,并且保持 16 字节对齐——因为 main 在调用 phase_1 的时候,已经提前把 phase_1 的返回地址(占用 8 字节)压入栈中,使得栈指针本身就偏离了 8 个字节。

可疑的地址复制操作

mov $0x402400,%esi 就是把数字 0x402400 放入 esi(Extended Source Index)寄存器中,这个寄存器存放的是将要读取数据的地址。欸!炸弹到这里就要读取什么东西了,这件事有点令人怀疑 0x402400 这个地址...后面就知道了。

strings_not_equal 调用

callq 0x401338 <strings_not_equal>,其中 0x401338 就是函数 strings_not_equal 的起始地址。对,到这里就是要比较输入字符串和正确字符串了。

需要注意的是,在 ABI 约定中,eax 寄存器保存着函数的返回值,所以这个 strings_not_equal 的比较结果(0 或 1)是最终存储在 eax 里的。

test %eax, %eax?????

test %eax,%eax 实际执行的是对寄存器 eax 自身做 AND 运算,但是却不会改变 eax 本身的值,只会根据结果去改变另一个叫做 标志寄存器 的值。根据 strings_not_equal 函数进行推断,如果字符串相等,即返回值为 0,则标志寄存器的零标志位将被写为 1,如果字符串不相等就是 0,所以这段指令就是在设置标志位。

惊心动魄的跳转

je 0x400ef7 <phase_1+23>,如果是 0,即字符串相等,即密码正确,那么就跳转到 callq 0x40143a <explode_bomb> 的下一条指令。如果字符串不相等,即密码错误,那么就不跳转。

Tip
在 x86 架构中,JE 实际上是 JZ(Jump if Zero)的别名,两者功能完全相同。
它们依赖标志寄存器中的零标志(Zero Flag, ZF)位:

ZF = 1 → 表示上一次比较或运算结果为 0(即相等) → 执行跳转
ZF = 0 → 表示结果不为 0(不相等) → 不跳转

BOOM!!!

很遗憾,输入了错误的密码,程序通过 callq 0x40143a <explode_bomb> 指令调用了可怕的自毁程序,可以重开了。

成功!!!

太棒了,输入了正确的密码,程序跳转到了爆炸指令的下一条指令,即 add $0x8,%rsp。清理最开始我们就多分配的 8 个字节,否则最后 ret 返回指令读取返回地址会错误地读取到 真正的返回地址 + 8,发生 SIGSEGV(Linux 强行停止了炸弹运行,我们安全了233)。

分析完成

我们已经知道了整个程序的运作流程,它写成 C 无非就是:

void phase_1(char *input)
{
    char *secret_string = (char *)0x402400;
    if (strings_not_equal(input, secret_string))
    {
        explode_bomb();
    }
}

那个正确密码就在 0x402400 所指的字符串里。于是我们在 GDB 里输入 x/s 0x402400,我们看到:

(gdb) x/s 0x402400
0x402400:       "Border relations with Canada have never been better."
(gdb)

密码出来了,是 Border relations with Canada have never been better.(唐突鉴证不可避,,,)验证一下对不对:

成功!

真是一场酣畅淋漓的逆向工程体验啊。

纹理参数空间和世界空间

在游戏里面求任意几何表面点的法向量,实际上就是对一个被嵌进三维欧几里得空间 的二维流形(它的坐标对应的就是纹理参数空间下的 )分别对 求偏导数。

求偏导数实际上就构造了一个切空间(Tangent Space)。简易起见,直接设参数空间 ,则给定一个有序数对 可返回一个世界空间坐标(点 ),即:

分别对 求偏导:

再次注意偏导数是一个向量,因为我们实际上是在对一个向量场 求偏导。得到偏导数之后,实际上我们就得到了分别沿着 的切向量,只需要把偏导数叉乘就可以得到垂直于它们的向量:

进一步:

我们就得到了点 处的法向量,其归一化形式是

“这件事本质上是...”,如果你经常和一些人讨论,当出现观点的分歧时,对方大概率会抛出这一句,他意图通过他认为的“本质”来(并以自己为胜者)结束话题。

对大多数人而言,“本质上”几乎是无意识地言说出来的。拉康说 "L'inconscient est le discours de l'Autre"(无意识是大他者的话语)

因为我们从小就教育所谓真理的神圣性,而本质与表象的二元对立又使得很多人如魔怔了一般去追求本质,用异轨自阿尔都塞的话讲,就是主体被大他者询唤,而这个大他者正是知识-权力的象征秩序。有一部分人虽然没那么魔怔,但是内心仍然持存着一份对知识的崇高敬畏,这些人就会实实在在地被那些掌握了知识的人用“本质上...”这种话语来欺负,并有可能留下精神创伤。

不要那么敬畏知识。对真理/知识的过于敬畏只会导致你:

  • 被“本质”话语的暴力所规训
  • 与知识本身天然产生一层隔阂
  • 敬畏权力

加粗是米歇尔·福柯指使的。

这学期学了什么

课程

这学期学校开了两门专业相关的课,一门是《计算机科学与工程导论》、一门是《C 语言程序设计》,然而只学它们会导致大脑被污染。学校学的东西落后无用(或者是晦涩难懂)=>自学已经是显而易见的规律。

《C 语言程序设计》似乎在之前的某篇文章里辣评了一下,这里不说了。但不得不承认的是,这门课发挥了它应有的价值:虽然学校自己的教授方法落后,作业考试大部分只是 intellectual masturbation,用的 IDE 是 Dev C++ 这种。为了让自己写得舒服点,我迫使自己掌握了 VS Code / VS + Git 的工作流,并且顺便熟悉了 Linux 操作系统等等。

所以,如果没有这么一套课程,我没有那么强烈的动力去做这些事情——这些事情其实就是 MIT 那套大名鼎鼎的 The Missing Semester of Your CS Education 课程会讲授的内容,但是我一直都没机会(或者说不敢/不想)上手做一遍。

《计算机科学与工程导论》这课上得也奇葩,老师就干讲 PPT,从数据结构讲到体系结构,整个学期就 2 次不痛不痒的作业。不过令人欣慰的是,有一次作业引导我们去思考递归算法和迭代算法对系统性能的影响,而不是完全聚焦于一些 intellectual masturbation 的功能实现,说明这门课其实并非一无是处。

LBD principle

Learn By Doing,这个谁都会讲,但是得自己做了才真的领略到。我不禁开始咒骂高中时期的自己,为什么那时就死活领会不到这个原则。不过也好,至少现在领会到了。

但是由于这个原则,我在相当长一段时间内又掉进了一个坑——就是看到什么都感觉想做一遍,并且经常 PUA 自己头脑里装的理论知识没有价值,觉得自己什么都不会做不如死了算了,搞得自己身心俱疲,并且学到的内容也很肤浅。知道自己想要什么再去做可能才是根本,我正尝试慢慢地接受这个事实。

重新学习

早在高中的时候就听说过 Lambda Calculus 的大名,但苦于认为自己没有一点系统的程序设计基础,于是对这些基础计算机科学敬而远之,后来才发现它们跟逻辑学、分析哲学其实是差不多的东西。

最近刷 QQ 空间看到有位朋友在一直推一些 Lambda 演算的科普视频,抱着好奇的心态点进去看,不到几分钟就立刻觉得自己以前学的完全不叫“计算机科学”(虽然这么说肯定是错的),于是想要好好了解一番这些东西。

所以自己又顺藤摸瓜找到了一些关于函数式编程的教程,不久前刚在 JavaScript 解释器上使用纯函数实现了单向链表的数据结构,第一次体验到以函数为 building block 的程序设计的灵韵——同时对计算的本质有了更加深入了理解;以及第一次知道程序还能这么学——就从单纯和解释器打交道开始,然后就直接是函数、抽象、递归······并且不受语言特性的打扰。最近书架上添置一本大名鼎鼎的程序设计教材,就是 SICP。希望以后能腾出足够的空闲时间好好领略。

不过这一定不是全部,好奇和求知的路没有尽头。

哇,2026 年的第一篇文章!

最近闲得无聊,又不想学 Unity 游戏制作(本来就不是很想学2333),居然买了本量子力学教材来消遣。虽然后面很难,但还是学到了很多东西,对自然界/微观世界的本质有了进一步的认识,同时也是我追星理论物理学家戈登·弗里曼的一大步(戈登的博士论文和量子力学有关)。

Schrödinger 方程简介

薛定谔(Schrödinger)方程是量子力学的基本方程,它与牛顿第二定律 在经典力学体系的地位相当。

这篇文章推导的薛定谔方程是定态的(time-independent),标准的含时薛定谔方程这次暂时不推,仅给出简单解释。

核心思想

能量守恒:总能量 = 动能 + 势能

这个其实就是标准薛定谔方程里哈密顿(Hamiltonian)量的核心。

Recap

波函数

在量子力学里,波函数用于描述一个量子系统的状态。

类比于声波的波幅,量子力学的波函数用记号 表示,可以看出它其实就是量子波幅,一个量子系统的状态由量子波幅刻画,强度由模方 表示。

测不准原理

其中 是约化普朗克常量。

由量子力学的另一位主要缔造者海森堡(Heisenberg)提出。

就是测量误差的意思,这个表达式想告诉我们的是:在任何情况下,我们都无法精确确定一个基本粒子的位置 和动量 总会存在 测量误差

所以,测量粒子在某个特定时间的状态是无意义的,我们只能通过描述它们的概率分布来寻求更为普遍的规律。这是量子力学与经典力学的根本区别。

能量守恒

本身是概率波的波幅,而概率波的强度 才能真正刻画概率分布,由于必须满足概率密度函数的非负性。直观地说, 越大的地方,在那里找到粒子的概率就越大。

接下来,将正式推导薛定谔方程:

一个粒子的总能量 等于动能 加上势能

而动能关系是我们非常熟悉的:

根据动量 ,动能可以写成:

得到能量守恒公式:

推导动量算子

德布罗意(De Broglie)关系告诉我们:粒子的动量 与波长 成反比:

其中 是约化普朗克常量, 是空间频率,也即在一米内有几个周期(波长)。

由能量守恒公式 知道,我们需要对 进行处理,由于量子力学下的动量遵循 ,它体现了物质粒子的波粒二象性。

先写一个最简单的波

在量子力学中,一个拥有确定动量 的自由粒子,其波函数通常用复指数形式表示(这比 处理起来更方便,因为 的导数还是它自己):

将波数 替换掉,波函数就变成了含有动量 的形式:

尝试提取动量

现在的目标是:寻找一个运算法则(算子),当它作用在 上时,结果等于 乘以

先试着写出对 的导数公式:

求导得到:

所以:

为了把 单独弄出来,于是把等式两边的常数移项:

分子分母同乘

我们得到了动量算子 ,它作用在波函数上就是上式的效果。写成表达式就是:

推导 对应的算子

算子的平方 意味着连续作用两次:

将我们刚才得到的定义代进去:

  • 常数部分:
  • 微分部分:(二阶导数)

于是:

最终,得到动量平方对应的算子:

推导方程

现在,我们将经典能量公式翻译成算子语言,作用在波函数 上:

还记方程的核心思想吗?那就是 总能量 = 动能 + 势能 ,我们刚刚又得到了动量算子,把它写成下面的形式:

注意现在整个表达式不是单纯的乘法,在数学上讲是一种变换。

代入刚才推导的动能算符,就得到了定态薛定谔方程:

方程含义

  • 动能项:

    • 描述了波函数的曲率(二阶导),当这个二阶导越大的时候(i.e. 波函数弯曲得越厉害的时候),动能越大。
  • 势能项:

    • 它是外部环境决定的,比如原子核对电子的吸引力,或者一个电场。它直接乘在 上,表示在位置 处的势能大小。
  • 总能量:

    • 这是一个常数。它告诉我们在这种波函数形态下,系统的总能量是多少。

假设一个电子被束缚在一个很小的盒子里,波函数必须弯得非常急才能使空间上的误差边界归零,这就导致动能极高:这解释了为什么原子核外的电子不会塌进原子核,因为如果不确定位置太小,动能就会大到把它弹开。

刚才的方程是不含时的,如果想知道波函数随时间 怎么演化,我们需要对时间求导,所以薛定谔方程的完整形态是:

其中 就是文章开头谈到的哈密顿量,它其实就是我们推导出来的动量算子和势能向相加:

既然薛定谔方程是个方程,那么就肯定要在实际情况下求解它。求解薛定谔方程其实就是在问:什么样的概率波形状 ,能让它的弯曲程度(动能)加上所在位置的势能,在各处加起来都等于同一个常数

凡是满足这个条件的波,就是电子可能存在的状态,比如原子里的 轨道、 轨道(死去的结构化学还在攻击我)

微分方程

微分方程里面有几个在学线性代数的时候有所耳闻的概念,例如特征方程、特征根、齐次线性方程等。

例如,下面就是个二阶齐次线性微分方程

它的特征方程 ,通过解这个二次方程便得到两个特征根。

然后还有个令人疑惑点就是设 ,不过书上说得对,它的导数与它本身只相差一个常数因子,这样方便进一步思考下去,但我还是感觉不妥当。

大一统

首先,需要建立一个统一的视角:算子(Operator)

高数第一章讲映射的时候就提到过,映射本身在不同的数学分支有不同的叫法,其中一个叫法就是算子

矩阵 维空间中的向量映射到 维空间中去,并且对加法和数乘运算满足线性,所以它是一个线性映射/线性算子

,它将函数空间中的某个函数映射到另一个函数空间,并且也对加法和数乘运算满足线性,它也是一个线性映射/线性算子

  • 线性代数研究的是矩阵 ,它作用于向量 (有限维向量空间)

  • 微分方程研究的是求导算子 ,它作用于函数 (无限维向量空间)

对应关系

为什么微分方程要“设”解为

在线性代数中,我们在寻找一组特殊的基底(特征向量),矩阵作用在它上面等效于直接乘以常数

  • 在线性代数里:

  • 在微分方程里: 我们在寻找一个函数,求导(算子 )之后还是它自己。只有一种函数满足“求导后形状不变,只变系数”,那就是指数函数

    • 就是微分算子 “特征向量”(准确地说叫特征函数

特征方程的由来

我们求解 。为了有非零解,必须要求行列式 ;

考虑一个微分方程 ,将它写成算子形式就是 如果我们把解限制在“特征函数” 的集合里,算子 就变成了代数变量 。于是算子多项式 就变成了代数多项式

杂交

对于二阶线性微分方程 ,引入变量 ,将其改写为矩阵形式:

以上可以优雅地简写为 ,此时可以发现矩阵方程与微分方程的特征方程完全一致

矩阵 的特征多项式是 。这与原微分方程的特征方程 完全一致!

并且,我们在这里首次揭示了导数算子 实际上可以用矩阵 来表示——求导和矩阵在本质上都是线性映射,它们在代数结构上存在一致。

它们本就一体

最后,来欣赏一下微分方程的通解和线性代数里特征向量的通解结构:

  • 线性代数:
  • 微分方程:

微分方程就是线性代数在无穷维向量空间下的情况进行考虑的。

骂人

《C 语言程序设计》已经接近尾声了,但感觉自己除了一些语法和标准库之外什么都没学到。

教材是学校自己编的,编的简直是一坨翔,某一页指针的示意图搞忘把实际的值换成地址都算轻的,讲二维数组甚至出现了“三行四列的行列式”这种已经被当成梗了的错误。

其它的部分更难评。感觉从教材到课程,教授们根本就没打算让我们学好这门课。

其次,学校买的 OJ 简直是我用过最不舒服的东西,包括里面的某些题,只觉 mind-bending 却用处不大,我整个学期都没花什么精力在那上面。

考试

写这篇文章发牢骚当然也是因为考试。我并不擅长做题,考试基本上只能做出大部分。不过我确实得认,因为没刷过题,平时作业完全随性做,老师布置的一个 82 道题的习题集我只做了 1 道。

我之前因为发牢骚还被同届的某“大神”攻击过,好像以后做什么事都跟这些考试题关系很大一样。虽然我确实会被这种聪明的智障气到,但我也理解这些“大神”:他们花了大量时间精力在刷题,就为了得个好看的名次并意淫自己的能力压别人一头。我认为这是 incompetent 的表现。

诈骗

今天讲动态内存管理,你猜讲了多久?只讲了十分钟不到!从引入 stack, heap 概念到讲完书上包含 malloc() 例子的实现,全程只花了几分钟!虽然我觉得很无聊,但是对于以前从来没接触过这些概念的同学,他们真的理解了吗?即使记得了几个名词,知道了语法该怎么写,但是究竟发生了什么谁知道呢?先暂且不谈现代操作系统上指针存的是 virtual address 还是 physical address 的问题,就说大家在提交作业的时候有时也会遇到的“段错误”(Seg fault)是什么意思,课上也不教。

书的序言还好意思着重强调 C 语言对计算机底层和内存管理的优势,这下倒好,这俩在你们的课程里都跟完全没存在感的 NPC 一样。完全就是诈骗。

认真你就输了

你反驳我就是你对。有人急了跟我说这些都是在打基础,我当然承认学校的作业和考试会训练敲码的肌肉记忆。那就当我在扯淡吧。

6502 是什么

6502 由 MOS Technology 于 1975 年发布的一款 8 位微处理器,流行于上世纪七八十年代的大部分家用 PC。

苹果 I/II, Commodore 64, 任天堂 NES/FC 游戏主机(后来的 SNES/SFC 主机也只是 6502 的 16 位扩展)等现象级家用机平台都基于 6502 体系结构。

项目

easy6502 有助于快速上手 6502 体系结构的汇编语言,链接为 https://github.com/skilldrick/easy6502

TODO:汉化

递推公式

Fibonacci 数列的递推公式为:

其中

矩阵表示

上式可以用矩阵表示:

求解方法

通过寻找与矩阵 相似的对角矩阵 ,可以利用 来得到

通项

特征多项式 ,令 解得特征值

对应的特征向量分别为 ,得到过渡矩阵及其逆矩阵:

对角矩阵:

由相似变换 得到

所以:

所以 Fibonacci 数列的通项公式为:

测量 CPU 时间

上篇文章写了个简单的蒙特卡洛估计器,用于估计 的值,但是那个非常慢,可以通过 System.Diagnostics 提供的 Stopwatch() 方法算一下时间。

var watch = new Stopwatch();

stopwatch.Start();

for (ulong i = 0; i < N; i++)
{
    x = rnd.NextDouble();
    y = rnd.NextDouble();
    if (x * x + y * y <= 1)
    {
        circleCount++;
    }
}
double PI = 4 * (double)circleCount / N;

watch.Stop();

Console.WriteLine("Pi = {0}, time = {1}ms", PI, watch.ElapsedMilliseconds);

设置采样数为 5000000000,得到 Pi = 3.1415527664, time = 37026ms。为了计算这个稳定于 3.1415 以上的结果,花费了 37 秒。

多线程

现代 CPU 具有多个物理核心,可以把它们利用起来。用 ThreadLocal<Random>() 为各个线程单独生成随机数。

using System.Threading;
using System.Threading.Tasks;

void ParallelEst()
{
    // 全局计数器
    long totalCircleCount = 0;

    var watch = new Stopwatch();
    ThreadLocal<Random> threadRnd = new ThreadLocal<Random>(() => new Random(Guid.NewGuid().GetHashCode()));    //各线程通过唯一 GUID 生成哈希值,防止随机样本序列重复

    watch.Start();

    // 循环并行化
    Parallel.For(
        0,
        (long)N,
        () => 0UL,
        (i, loopState, localCount) =>   // i: 当前迭代,localCount: 当前线程的局部计数器
        {
            Random rnd = threadRnd.Value;
            double x = rnd.NextDouble();
            double y = rnd.NextDouble();
    
            if (x * x + y * y <= 1)
            {
                localCount++;
            }
            return localCount; 
        },
        localCount =>  // 累积局部计数器到全局计数器
        {
            Interlocked.Add(ref totalCircleCount, (long)localCount);
        }
    );

    threadRnd.Dispose();

    double PI = 4 * (double)totalCircleCount / N;

    watch.Stop();

    Console.WriteLine("Pi = {0}, time = {1}ms", PI, watch.ElapsedMilliseconds);
}

以下是运行时间:

1 线程 24 线程
36412 ms 6348 ms

多线程的确占用起来了:

taskmgr

踩坑

之前是这么写的,结果并行化反而慢了 10 倍:

void ParallelEst()
{
    ulong circleCount = 0;
    
    var watch = new Stopwatch();
    
    ThreadLocal<Random> threadRnd = new ThreadLocal<Random>(() => new Random(Guid.NewGuid().GetHashCode()));
    
    watch.Start();
    
    Parallel.For(0, (long)N, i =>
    {
        Random rnd = threadRnd.Value;
        double x = rnd.NextDouble();
        double y = rnd.NextDouble();

        if (x * x + y * y <= 1)
        {
            Interlocked.Increment(ref circleCount); // 锁在线程循环里面
        }
    });
    
    threadRnd.Dispose();
    
    double PI = 4 * (double)circleCount / N;
    
    watch.Stop();
    
    Console.WriteLine("Pi = {0}, time = {1}ms", PI, watch.ElapsedMilliseconds);
}

原因是每个线程每次累计一次 circleCount 都要调用一次 Interlocked 方法,导致线程之间产生锁竞争,大幅降低并发性能。

修复方案就是给单个线程分配一个局部的计数器 localCount,等各线程累计结束之后再调用原子操作。

0%