MoreRSS

site iconsmallyu修改

区块链行业的开发者。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

smallyu的 RSS 预览

我从王垠的计算机科学课学到了什么(2个月版)

2025-08-24 12:12:12

为了行文方便,这篇文章不使用 “王垠老师” 这样的尊称,直接称呼名字 “王垠”。

学习时长

大约 3 个月前,我开始报名学习王垠的 计算机科学视频班(基础班),经过 1 个月的学习后毕业,大概用了 120 个小时的学习时长。“120 个小时” 这个数字是经过认真估算的,包含了观看视频的时间和做练习题的时间。因为视频课的学习节奏由自己把控,毕业速度因人而异。

如果我说学了 1 个月,也许有人很不在乎,1 个月的时间能学到多少知识呢?世界上没有速成班,王垠也不可以。1 个月的时间确实不可能学到各种全面的知识,时间上也不允许。但是 1 个月的时间能不能帮我把过往的编程技能梳理一遍,让我对计算机科学有更加体系化的认识?我在工作中可能见过各种迷雾,王垠的课程能不能让我拨云见日,看清楚很多东西?

学习课程就像是去西天取经,顺利毕业相当于取到了真经。但是学习课程的过程只是老师帮你理解经书内容而已,取到真经不代表你已经对经书中的内容融会贯通,还需要在日后多加修炼、理解透彻才行。想必有的同学拿到经书(毕业)后再也不会打开看一眼,于是认识不到课程真正的价值含量。

课程体会

现在课程毕业后,经过 2 个月时间的沉淀,我想分享一下 “从课程中能学到什么” 这个话题。没有在毕业当天做总结,是因为怕有点浮燥总结不全,或者掺杂太多个人经历。2 个月的时间其实也不够,我没有太多时间复习,课程也远远没来得及发挥出应有的成果,但是现在做总结并不妨碍以后对课程内容有进一步的感悟。

王垠曾经有一篇文章《爱因斯坦谈教育》,里面提到爱因斯坦说, “被传授的知识应该被当成宝贵的礼物”。我在学完课程后,对这句话有了切实的理解。

王垠并不认识我,但我早就认识王垠;我以前不是王垠的学生,但王垠早就是我的老师了。我关注王垠的博客多年,已经从他的博客文章中受益很多。而这次系统学习了视频课的课程,像是打开了新世界的大门。这并不夸张,我可以负责任地站在学习过课程的立场上讲,王垠没有吹牛,他的课程真的有他说的那么好。

你可以不赞同王垠的观点,也可以不喜欢王垠的人生态度,但是不可能怀疑王垠在计算机科学,尤其是编程语言领域的研究水平和造诣,也完全不需要担心王垠这样对自己和世界都如此较真的人,会拿一些没有含量的课程出来忽悠人。

知识深度

因为课程内容是保密的,我不会透露课程具体的学习内容,只基于公开的招生说明中的内容进行解释。

对于计算机初学者来说,从课程中能学到的最直接的知识,就是函数、链表、二叉树等基础的编程概念,涵盖了大学本科整个阶段的学习内容。第 6 课关于解释器的部分,属于课程的 “最终成果”,对应一些美国顶尖大学,本科高年级阶段至硕士低年级阶段的学习内容。

为什么王垠在招生说明里的描述是 “大学博士阶段才可能学到的内容” 呢?因为中国的大学没有编程语言专业,本科课程没有教解释器的,即使到了研究生阶段,lambda 演算也属于选修课,博士早期才会接触到解释器的实现是正常现象。所以王垠的描述真的没有夸大。

解释器这种内容在中国的教育体系里本来就很稀有,比较高级和精练的教程更是少见。举例来说,当你学完了王垠的课,然后去 bilibili 上搜一些解释器相关的教程,你就会明白这些公开教程里的解释器有多么差劲,不但一眼就能看出它们缺什么要素、存在什么问题,还知道如何改进、如何用最简洁的代码写出最可靠的实现。

为什么解释器这个东西重要呢?举个例子,以太坊的虚拟机(EVM)就是一个解释器,只不过 EVM 并不是在对编程语言做解释,而是在对以太坊的操作码(Opcodes)做解释,每个操作码都对应在栈结构上的一个动作。所以学过了解释器之后,对 EVM 的原理会有不一样的理解。

认知提升

对于有计算机经验的学生来说,从课程上可以学到的,就不只是表面上的知识了。比如,课程只用到非常少的编程要素,就表达了第 1 课到第 6 课的全部内容,如果王垠不是对计算机理论有非常深刻的理解,不可能做到这种地步的深入简出。

从学习者的角度,一方面可以思考为什么课程内容能如此精致,组织这些课程内容的思路是什么,这种高度抽象的思维背后,需要怎样的功底,自己距离写出这样的课程,能力上的差距还有多大。另一方面,由于课程内容自成一体,学习者完全有可能做到自己复刻整个课程内容,就像是手里的一个精致的玩物,可以随时拿出来复习把玩。

基础班的知识像是非常高级的原材料,从基础班毕业就意味着拿到了这些原材料。但原材料需要经过反复打磨、锤炼、加工,才能变成更加实际可用的装备。所以猜测有的同学学完之后感觉什么都没学到,而有的同学觉得如获至宝,能够反复加以利用并产生许多价值,大概就是这个原因吧。

自学能力

我最近忽然意识到,学完课程以后,学习其他技术好像变简单了。因为体验了课程中层层递进的教学方式,我自学其他东西也会按照这个思路来,一步一步学,自然而然就学会了.

理解 CPS、基于 continuation 原理实现协程调度,学习 Solana 合约、比特币脚本、比特币 Runes、DeFi,等等。我有点说不上这是怎么回事,明明课程里只教了一些计算机基础,链表、二叉树什么的,竟然有这样的威力。

也许从课程里学到的不只是知识,还有很好的学习方法。难怪王垠把课程内容称为 “计算机科学的精华”。

超越工程的技术

虽然我有多年在区块链行业实际的工作经验,但是我却越来越搞不懂,区块链技术到底是什么。

我曾经大量研究区块链共识的原理,为什么关注的不是其他技术原理,比如加密学、分布式网络、储存系统等内容?因为只有共识是区块链特有的,其他都是普通的工程上的技术,包括智能合约的实现也是,而且智能合约还不是区块链必需,可以有也可以没有。至于 Layer 2 之类,也完全是工程上的尝试,哪有什么可以抽象的理论依据,挑战期、赎回期等都是业务定义的逻辑。

可以剧透一下,王垠的计算机课程,还涉及到了一部分区块链最本质的技术原理,是不是难以想象?

我发现,与不成体系的区块链工程技术相比,拥有基础而扎实的计算机功底更重要,尤其是高度抽象的思维方式,能够脱离语法但理解编程语言本质的学习方式,值得反复琢磨和理解。

我曾经以为,只要好好钻研区块链技术,就可以逐步提高自己的技术能力,就可以深入研究某些区块链原理,深入再深入,水平上自然而然超越所谓的计算机基础班,这也是我前几年没有报名学习课程的原因。然而事实上不是那样,经过几年切实的工作,我越发认识到课程内容的重要性。所以现在学习了课程,并且学到了很多。

课程的启发性

学完课程以后,我其实一度怀疑自己,难道这几年折腾区块链技术都是没有意义的吗?为什么学了一个零基础入门性质的计算机课,反而感觉学到了能 “改变人生” 的东西?

一直没想通这个问题,直到后来,偶然看到王垠讲述课程设计的文章《计算机科学课程》,尤其是看到 “苏格拉底方法” 段落的时候,恍然大悟,原来我的很多知识早已散落在我的头脑中,只是这门课程帮我把知识都 “生产” 了下来。我现在拥有的知识,离不开经年累月工作和学习的 “怀胎”,也离不开这门计算机课程在恰当时候的 “助产”。

苏格拉底承认他自己本来没有知识,而他又要教授别人知识。这个矛盾,他是这样解决的:这些知识并不是由他灌输给人的,而是人们原来已经具有的;人们已在心上怀了“胎”,不过自己还不知道,苏格拉底像一个“助产婆”,帮助别人产生知识。

这就好比,在一个陌生的城市里,你没有地图,搞不清方向,但是也可以四处游荡,有时候能走的很远。而王垠的计算机课程,像是一张完整的地图,你有了地图,各个方向和岔路口都清清楚楚,但是你未必出发,也未必耗费力气去探索远方。

我的情况相当于,已经在城市里走过了很多路,偶然有一天,拿到了整个城市的地图,幡然醒悟,明白了自己所处的位置,知道了自己原先走过哪些路。这是课程对我帮助很直接的地方。

真正的编程

前几天面试的时候,面试官出了一道算法题,我拿到题目后,下意识说 “这看起来不是一道编程题,而是一些数字游戏”。我猜面试官听到我的话后,内心是充满鄙夷的,他也许会想,“这怎么不是编程题?”

我事后也惊讶,当时为什么会那样评价面试官出的题目。那是一种下意识的感受。

后来想明白,因为你一旦上过王垠的课,就会知道真正的 “编程题” 是什么。LeetCode 上那些算法题,“编程” 的含量有多少呢?学完后课程后,你可以轻易看穿那些低水平面试官的把戏。

代码质量

王垠最近在微博上评论 AI 编程的时候提到,AI 很厉害,但 AI 无法写出 “王垠级别” 的代码。

什么是 “王垠级别” 的代码呢?上过课就知道了。我在做练习题的时候,被助教提醒最多次的问题,就是 “代码复杂”,有时候是写法上的复杂,有时候是复杂度上的复杂,但是每一次把代码写到符合课程标准之后,又不禁感叹原来代码可以如此精巧。

我有多年的编程经验,让代码运行出练习题的结果并不难,但是把代码写的足够漂亮却不容易。“代码能运行” 和 “把代码写对” 之间,差距不是一般的大。假如以前给公司写的都是这种质量的代码,那公司可就太占便宜了。

掌握新语言

也许有人看到招生说明会怀疑,一节选修课真能让人学会一种新的编程语言吗?

我想提醒的是,不要忘了给你讲课的人是谁,是曾经写出了《如何掌握所有的程序语言》文章的作者,是真正的编程语言专家。

课程的定位

我上面的描述,可能存在大量情绪表达,会让人看不懂是什么意思。

我觉得王垠的课,像是某种核心(因为基础),只要掌握了这些核心内容,你就始终算是会编程、懂计算机科学的人。

这里说的基础,不是 Hello World 那种基础,而是某种结构化的基础。这些基础知识,会帮你理解 “计算” 的本质。这些核心的基础知识,才是日后真正支撑你前进、给你力量的来源。无论上层的表达形式如何变化,本质都不会变。

我以前经常担心,会不会因为做了某些工作,导致自己偏离原先的技术道路?做一些运维相关的工作,是不是就偏离了自己开发者的初心?原本做 Go 语言开发,工作需要写一些 node.js 代码,是不是就偏离后端开发的职业路径?等等。

王垠的课程内容,就属于有 “定心丸” 功效的那种。课程教给你的,是如何搭建一套自己的 “计算模型”,而不是告诉你某些已经存在的项目具体是怎么回事。所以不要纠结于课程内容是不是全面、是不是高级,因为课程本身是讲心法的,不是讲招式的。至于具体的招式,你得自己学、自己练。

学习课程以前,我在挑选工作的时候,能不能从工作中学到东西、工作有没有成长空间、有没有发展前景,都是重点需要考虑的。学完课程以后,我在考虑的,是如何根据已经掌握的知识和技能,给团队带来帮助、给公司创造价值。心态完全不一样了。因为我不再需要从工作中学到什么,我已经知道从哪里可以学到真正的技术。

当然,这事我觉得,也要看悟性。课程教归教,能收获多少,还得靠自己。

DeFi 基础: 理解 AMM 定价机制

2025-08-20 22:00:00

这是一个 DeFi 系列教程,在动手实践的过程中,学习和理解 DeFi 相关的概念与原理。因为篇幅问题,博客只放第 1 篇:

  1. DeFi 基础: 理解 AMM 定价机制
  2. DeFi 基础: 预言机与报价
  3. DeFi 基础: 借贷与清算
  4. DeFi 进阶: 闪电贷与套利

AMM 的全称是 Automated Market Maker,自动做市商,作用是不需要订单簿撮合交易,就可以自动完成定价与交易。

这篇文章解释了 Uniswap V2 的核心定价逻辑,并且提供了完整的合约代码示例、命令行操作步骤、实际的链上交易现场等,作为理解 AMM 的配套参考。

AMM 计算公式

基本逻辑

Uniswap V2 用的定价逻辑是恒定乘积做市商(Constant Product Market Maker, CPMM),也是我们的示例 AMM 合约在用的方法。这里有一个恒等公式:

x * y = k

意味着池子里有两种资产 xy,当 x 增多的时候,y 就应该减少,y 增多的时候,x 应该减少,k 总是保持不变。

在添加初始流动性的时候,我们第一次确定下来这个 k 的值,比如我们按照 2000 USDC / 1 WETH 的价格注入初始流动性,会得到(不考虑精度):

k = 2000

当我们想要用 USDC 换出 WETH 的时候,池子里的 USDC 增多,为了保持 k 不变,合约会计算应该保留多少 WETH,然后把相应数量的 WETH 转给我们。

第一次兑换

当我们想要用 USDC 换出 WETH 的时候,池子里的 USDC 增多,为了保持 k 不变,合约就会把相应数量的 WETH 转给我们了。

例如,我们试图用 500 USDC 换出 WETH,此时加上初始流动性的 2000 USDC,池子里一共 2500 USDC,那么:

x = 2500y = k/x = 2000/2500 = 0.8

这个 0.8 意味着,为了保证 AMM 池子里的 k 值恒定为 2000,池子需要转出 0.2 WETH。也就是说,我们会得到 0.2 个WETH。

第二次兑换

我们再来用 500 USDC 买一次,此时池子里一共有 2500+500=3000 USDC,则:

x = 3000y = k/x = 2000/3000 = 0.667

这个恒定乘积公式计算得出池子里应该保留 0.667 个 WETH,上一轮交换后还剩 0.8 WETH,所以这一轮我们实际得到 0.8-0.667 = 0.133 WETH。

对比来看,第一次用 500 USDC 可以换出 0.2 WETH,第二次用 500 USDC 就只能换出 0.133 WETH 了。随着池子里流动性的减少,WETH 的价格涨了。

价格曲线

这就是自动做市商的核心逻辑,价格不是写死的,而是根据池子中剩余的流动性算出来的。要注意 x 和 y 的乘积是一条曲线,因为 y=k/x,画成图是这样:

接下来会用实际的操作步骤与链上交互,来体验 AMM 的运作。

示例合约

合约代码源文件在仓库:smallyunet/[email protected]

首先准备两个合约,一个是 TestERC20.sol,比起标准的 ERC-20 合约,支持自定义代币精度,以及随意 mint 一些代币。

第二个要准备的合约是 SimpleAMM.sol,提供了对代币增加流动性、代币兑换等功能。合约代码不算很简单,我们会在接下来实际的操作用,逐步体会和理解这个合约的功能,以及解读源代码。

以下所有操作都在以太坊的测试网 Sepolia 上进行。

环境准备

准备好命令行工具,以及设置两个环境变量:

foundryupexport RPC_URL="https://ethereum-sepolia-rpc.publicnode.com"export PK_HEX="<YOUR_PRIVATE_KEY_HEX>"

下载合约仓库、进入到仓库根目录:

git clone https://github.com/smallyunet/defi-invariant-lab/git switch v0.0.1cd defi-invariant-lab

部署代币合约

部署合约

部署两个测试版本的 ERC-20 代币,一个叫 USDC,一个叫 WETH:

forge create \  --rpc-url $RPC_URL \  --private-key $PK_HEX \  --broadcast \  contracts/libs/TestERC20.sol:TestERC20 \  --constructor-args "USD Coin" "USDC6" 6

部署的合约地址是:0x84637EaB3d14d481E7242D124e5567B72213D7F2

forge create \  --rpc-url $RPC_URL \  --private-key $PK_HEX \  --broadcast \  contracts/libs/TestERC20.sol:TestERC20 \  --constructor-args "Wrapped Ether" "WETH18" 18

部署的合约地址是:0xD1d071cBfce9532C1D3c372f3962001A8aa332b7

验证合约

如果愿意,可以这样验证下合约:

export ETHERSCAN_API_KEY=你的keycast abi-encode "constructor(string,string,uint8)" "USD Coin" "USDC6" 6forge verify-contract \  --chain-id 11155111 \  0x84637EaB3d14d481E7242D124e5567B72213D7F2 \  contracts/libs/TestERC20.sol:TestERC20 \  --constructor-args "0x000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000855534420436f696e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000055553444336000000000000000000000000000000000000000000000000000000" \  --etherscan-api-key $ETHERSCAN_API_KEYforge verify-contract \  --chain-id 11155111 \  0xD1d071cBfce9532C1D3c372f3962001A8aa332b7 \  contracts/libs/TestERC20.sol:TestERC20 \  --constructor-args $(cast abi-encode "constructor(string,string,uint8)" "Wrapped Ether" "WETH18" 18) \  --etherscan-api-key $ETHERSCAN_API_KEY

部署 AMM 合约

部署合约

这里的参数 30 指收取 0.3% 的手续费:

forge create \  --rpc-url $RPC_URL \  --private-key $PK_HEX \  --broadcast \  contracts/amm/SimpleAMM.sol:SimpleAMM \  --constructor-args $USDC_ADDR $WETH_ADDR 30

部署的合约地址是:0x339278aA7A09657A4674093Ab6A1A3df346EcFCF`

验证合约

forge verify-contract \  --chain-id 11155111 \  0x339278aA7A09657A4674093Ab6A1A3df346EcFCF \  contracts/amm/SimpleAMM.sol:SimpleAMM \  --constructor-args $(cast abi-encode "constructor(address,address,uint16)" $USDC_ADDR $WETH_ADDR 30) \  --etherscan-api-key $ETHERSCAN_API_KEY

mint 代币

声明钱包地址与合约地址:

export MY_ADDR=0x44D7A0F44e6340E666ddaE70dF6eEa9b5b17a657export AMM_ADDR=0x339278aA7A09657A4674093Ab6A1A3df346EcFCFexport USDC_ADDR=0x84637EaB3d14d481E7242D124e5567B72213D7F2export WETH_ADDR=0xD1d071cBfce9532C1D3c372f3962001A8aa332b7

挖 100 万个 USDC,精度是 6 位数:

cast send $USDC_ADDR "mint(address,uint256)" $MY_ADDR 1000000000000 \  --rpc-url $RPC_URL --private-key $PK_HEX

挖 1000 个 WETH,精度是 18 位数:

cast send $WETH_ADDR "mint(address,uint256)" $MY_ADDR 1000000000000000000000 \  --rpc-url $RPC_URL --private-key $PK_HEX

铸币的交易与结果可以直接在浏览器上看到,这个 是挖 USDC 的交易,这个 是挖 WETH 的交易。

给 AMM 合约授权

给 AMM 授权是因为接下来想要给 AMM 添加流动性,添加流动性会调用 addLiquidity 函数,其中用到了 transferFrom,所以需要先给合约授权,让合约可以动用我的 USDC 和 WETH 代币:

cast send $USDC_ADDR "approve(address,uint256)" $AMM_ADDR "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" \  --rpc-url $RPC_URL --private-key $PK_HEXcast send $WETH_ADDR "approve(address,uint256)" $AMM_ADDR "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" \  --rpc-url $RPC_URL --private-key $PK_HEX

交易哈希分别是 USDCWETH

添加初始流动性

添加流动性的 函数 比较简单,大概是合约里有两个变量 reserve0reserve1,调用 addLiquidity 函数的时候,会向 AMM 合约转账参数数量个代币。

先以 2000 USDC / 1 WETH 的价格,添加初始流动性:

cast send $AMM_ADDR "addLiquidity(uint256,uint256)" 200000000000 100000000000000000000 \  --rpc-url $RPC_URL --private-key $PK_HEX

交易 完成后,可以查询到 AMM 合约剩余的代币数量:

cast call $AMM_ADDR "getReserves()(uint112,uint112)" --rpc-url $RPC_URL# 200000000000 [2e11]# 100000000000000000000 [1e20]

用 USDC 换 WETH

合约代码解读

我们的合约代码 swap0For1 是这样:

function swap0For1(uint256 amtIn) external returns (uint256 out) {    require(token0.transferFrom(msg.sender, address(this), amtIn), "t0in"); // 把用户的 x 转进合约    uint256 r0 = token0.balanceOf(address(this)); // 查询当前 x    uint256 r1 = token1.balanceOf(address(this)); // 查询当前 y    uint256 amtInEff = (amtIn * (10_000 - feeBps)) / 10_000; //计算扣除手续费后,用户转入了多少 x    // x*y=k, solve out = r1 - k/(r0)    uint256 k = (r0 - amtInEff) * r1;   // 计算 k    out = r1 - Math.ceilDiv(k, r0);     // 计算给用户多少 y    require(token1.transfer(msg.sender, out), "t1out");}

函数代码体现了刚才描述的关于 x*y=k 的恒定公式。因为 AMM 合约考虑到收手续费的问题,所以有一个 amtInEff 用来表示用户实际转入了多少 x。

测试第 1 次交换

我们来实际发起交易,看看合约运行后的效果,先试着用 1000 USDC,看能换多少个 WETH 出来:

cast send $AMM_ADDR "swap0For1(uint256)" 1000000000 \  --rpc-url $RPC_URL --private-key $PK_HEX

交易 完成后,查看一下代币余额:

cast call $USDC_ADDR "balanceOf(address)(uint256)" $MY_ADDR --rpc-url $RPC_URLcast call $WETH_ADDR "balanceOf(address)(uint256)" $MY_ADDR --rpc-url $RPC_URLcast call $AMM_ADDR "getReserves()(uint112,uint112)" --rpc-url $RPC_URL

其实区块链浏览器上能很直接的看到交换的数量,交易哈希是:0xf13bd1d1602d7c106c2acdf4cb3b1ec37fa42d8871a682e32cce3f2049fff5a2

我们转出了 1000 USDC,收到了 0.496019900497512437 个 WETH。这里因为有 0.3% 的手续费,所以收到的 WETH 不是 0.5。

除了手续费,还存在一个价格的问题,按理来说,随着剩余 WETH 数量的减少,WETH 的价格会越来越高。

测试第 2 次交换

再来用 1000 USDC 兑换一次,看能换出多少 WETH:

cast send $AMM_ADDR "swap0For1(uint256)" 1000000000 \  --rpc-url $RPC_URL --private-key $PK_HEX

这次兑换的交易哈希是:0x1ee9ceb0707d77d78669bfb6cc1179bf9d6b31c57b868f5f52ed2f01a4127481

这一次,花费了 1000 USDC,收到了 0.491116179005960297 个 WETH。与上一次兑换的结果相比,收到的 WETH 真的减少了。

用 WETH 换 USDC

可以自己测试玩一下。

Go 语言 GMP 调度器的原理是什么

2025-08-18 21:50:50

声明:我看不起 “Go 语言 GMP 调度器的原理是什么” 这种技术话题。

我平时没兴趣研究这种问题。因为在面试中被问到的频率太高了,现在想花 2 个小时的时间来了解下。一方面研究下这个问题背后到底有多大的技术含量,另一方面把这个问题的答案写下来。但是我不会让这种内容停留在我的头脑里,所以下次面试被问到,我肯定还说不会 😏

基本概念

GMP 是一个缩写:

  • G(goruntine):就是协程,代码里每 go 一个,G 的数量就多一个
  • M(Machine):就是系统级别的线程,在其他语言里的 thread
  • P(Processor):数量为 GOMAXPROCS,通常默认是 CPU 核心数。

GMP 的意思是,启动多少个 M(线程) 来执行 G(协程),最多允许 P(核心数)个 M 并行执行。

三个不变量

无聊的(简化后的)定义来了:

  1. 只有拿到 P 的 M 才能执行任务
  2. 可运行的 G 只会在某个 P 的本地 runq 或者全局队列
  3. 当 M 进入阻塞状态(syscall/cgo)时,会及时把 P 让出

这几句话看着很费劲,不需要现在理解,接下来会用一些代码例子来说明他们的含义。

GMP 的调试日志

这是一个最简单的代码文件,用来演示启动一个协程:

package mainimport (    "fmt"    "sync")func main() {    var wg sync.WaitGroup    wg.Add(1)    go func() {        defer wg.Done()        fmt.Println("Hello from goroutine")    }()    wg.Wait()}

然后带上调试参数运行一下:

go build demo0.goGODEBUG='schedtrace=200,scheddetail=1' ./demo0

注意不要用 go run,因为会引入一些 Go 语言运行时的日志。这个二进制版本的日志比较干净,内容是:

SCHED 0ms: gomaxprocs=10 idleprocs=7 threads=5 spinningthreads=1 needspinning=0 idlethreads=0 runqueue=0 gcwaiting=false nmidlelocked=1 stopwait=0 sysmonwait=false  P0: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0  P1: status=1 schedtick=0 syscalltick=0 m=2 runqsize=0 gfreecnt=0 timerslen=0  P2: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0  P3: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0  P4: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0  P5: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0  P6: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0  P7: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0  P8: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0  P9: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0  M3: p=0 curg=nil mallocing=0 throwing=0 preemptoff= locks=1 dying=0 spinning=true blocked=false lockedg=nil  M2: p=1 curg=nil mallocing=0 throwing=0 preemptoff= locks=6 dying=0 spinning=false blocked=false lockedg=nil  M1: p=nil curg=nil mallocing=0 throwing=0 preemptoff= locks=2 dying=0 spinning=false blocked=false lockedg=nil  M0: p=nil curg=nil mallocing=0 throwing=0 preemptoff= locks=0 dying=0 spinning=false blocked=true lockedg=1  G1: status=1() m=nil lockedm=0  G2: status=4(force gc (idle)) m=nil lockedm=nilHello from goroutine

这些日志显示了这些信息:

  • 第一行 SCHED 开头的是汇总信息,告诉我们程序启动了 10 个 P(gomaxprocs=10)。
  • 只有 P1M2 拿着运行
  • P0M3 拿着处于 spinning 状态,也就是等待任务的状态。

没看到 print 相关的 G,是因为任务运行时间太短了,没被 trace 捕获就结束了,这里主要展示 GMP 的详细信息可以用 debug 命令来看。

抢占式调度

package mainimport (    "fmt"    "runtime"    "time")func busy(tag string, d time.Duration) {    end := time.Now().Add(d)    x := 0    for time.Now().Before(end) {        x++    }    fmt.Println(tag, "done", x)}func main() {    runtime.GOMAXPROCS(1)    go busy("A", 1500*time.Millisecond)    busy("B", 1500*time.Millisecond)}

这个代码的运行结果是,有时候 AB 前面,有时候 BA 前面。

我们已经用 runtime.GOMAXPROCS(1) 设定只有一个 P,但是 Go 语言的 GMP 调度器,仍然会 10ms 释放一次时间片,也就意味着,即使 go busy("A") 处于阻塞状态,时间片之后也会让出执行权,交给主线程去运行 B

可以用这个 busy 的函数定义来让抢占式调度更加肉眼可见:

func busy(tag string, d time.Duration) {    end := time.Now().Add(d)    next := time.Now()    for time.Now().Before(end) {        if time.Now().After(next) {            fmt.Print(tag, " ") // 每 ~100ms 打印一次            next = time.Now().Add(100 * time.Millisecond)        }    }    fmt.Println(tag, "done")}

程序的打印结果会是 B A B A B A A B A B A B A B A B A B A B A B A B B A B A B A B done。这意味着不是 tag 为 A 的 P 一路执行到底,也不是 tag 为 B 的 P 一路执行到底,他们在 GMP 调度器中交替执行。

P 偷活干(work-stealing)

来看这个代码示例:

package mainimport (    "runtime"    "sync"    "time")func spin(d time.Duration) {    deadline := time.Now().Add(d)    for time.Now().Before(deadline) {    } // 纯CPU忙等}func main() {    runtime.GOMAXPROCS(1) // 先让所有 G 挤到同一个 P 的本地队列    const N = 120    var wg sync.WaitGroup    wg.Add(N)    for i := 0; i < N; i++ {        go func() { defer wg.Done(); spin(500 * time.Millisecond) }()    }    time.Sleep(30 * time.Millisecond) // 给点时间把队列堆满到 P0    runtime.GOMAXPROCS(4) // 突然放大并行度:P1~P3 会去“偷” P0 的一半    wg.Wait()}

这个代码干了什么呢,首先设定之后一个 P,然后启动 120 个 G 给这个 P 去执行。30 毫秒后,突然增大 P 的数量。

用 debug 日志能看到,运行后半段有这样的日志:

P0: status=1 schedtick=46 syscalltick=2 m=0 runqsize=17 gfreecnt=0 timerslen=0P1: status=1 schedtick=58 syscalltick=0 m=4 runqsize=5 gfreecnt=15 timerslen=0P2: status=1 schedtick=60 syscalltick=0 m=2 runqsize=5 gfreecnt=18 timerslen=0P3: status=1 schedtick=42 syscalltick=0 m=3 runqsize=17 gfreecnt=0 timerslen=0

也就是说,本应该 G 全在 P0 上运行,等到 P1、P2、P3 出来后,它们发现 P0 很忙,就去 P0 的队列里拿了几个任务过来执行。

P 的 runq 队列和全局队列

一个 P 想找活干的时候,上面的代码是偷其他 P 的示例。更严谨的流程是,P 先从本地 runq 队列找,再到全局队列找,找不到再去偷其他 P 的。

什么是 runq 队列,什么是全局队列?可以看这个代码:

package mainimport (    "runtime"    "sync"    "time")func spin(d time.Duration) {    end := time.Now().Add(d)    for time.Now().Before(end) {    } // 纯CPU忙等:保持 runnable}func main() {    runtime.GOMAXPROCS(1) // 只有 P0:所有新 G 先进入 P0 的本地 runq    const N = 600 // 让它明显超过本地 runq 容量(当前实现通常是 256)    var wg sync.WaitGroup    wg.Add(N)    for i := 0; i < N; i++ {        go func() { defer wg.Done(); spin(800 * time.Millisecond) }()    }    time.Sleep(500 * time.Millisecond) // 给运行时时间把“溢出的一半”推到全局队列    runtime.GOMAXPROCS(4) // 其它 P 进场,会先从“全局队列”拿活(不是偷)    wg.Wait()}

debug 状态运行:

go build demo4.go   GODEBUG='schedtrace=200,scheddetail=1' ./demo4 &> demo4.log

日志会比较多,日志前面几行像这样:

SCHED 0ms: gomaxprocs=10 idleprocs=9 threads=2 spinningthreads=0 needspinning=0 idlethreads=0 runqueue=0 gcwaiting=false nmidlelocked=0 stopwait=0 sysmonwait=false  P0: status=1 schedtick=0 syscalltick=0 m=0 runqsize=0 gfreecnt=0 timerslen=0  P1: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0

其中首行的 runqueue=0 就是全局队列,P0 后面的 runqsize=0 是 P0 的本地队列,P1 后面的 runqsize=0 是 P1 的本地队列。可以看到此时的 P1 状态是 0,也就是不可运行。

随着程序的运行,P0 会启动非常多个 G,日志状态是这样:

SCHED 200ms: gomaxprocs=1 idleprocs=0 threads=5 spinningthreads=0 needspinning=1 idlethreads=3 runqueue=395 gcwaiting=false nmidlelocked=0 stopwait=0 sysmonwait=false  P0: status=1 schedtick=10 syscalltick=2 m=0 runqsize=204 gfreecnt=0 timerslen=1

一般 P 的本地队列默认是上限是 256,达到这个峰值后,就会把任务溢出到全局队列。

再然后,P1、P2、P3 启动,开始从全局队列拿任务(全局队列有任务则不需要偷其他 P 的):

SCHED 826ms: gomaxprocs=4 idleprocs=0 threads=5 spinningthreads=0 needspinning=1 idlethreads=0 runqueue=217 gcwaiting=false nmidlelocked=0 stopwait=0 sysmonwait=false  P0: status=1 schedtick=35 syscalltick=2 m=0 runqsize=179 gfreecnt=0 timerslen=0  P1: status=1 schedtick=14 syscalltick=0 m=3 runqsize=90 gfreecnt=0 timerslen=0  P2: status=1 schedtick=14 syscalltick=0 m=4 runqsize=64 gfreecnt=0 timerslen=0  P3: status=1 schedtick=13 syscalltick=0 m=2 runqsize=46 gfreecnt=0 timerslen=0

另外,当 P 依次从本地 runq、全局队列、其他 P 都找不到任务时,会再去问一下 netpoll(问一下 OS)有没有新的 G,要是有就执行,没有就自旋(待命)。这就是 P 执行任务的逻辑。

阻塞 syscall 会及时让出

看这个代码例子:

package mainimport (    "fmt"    "runtime"    "time")func main() {    runtime.GOMAXPROCS(2)    go func() {        time.Sleep(2 * time.Second) // 类比阻塞 syscall/cgo        fmt.Println("blocking done")    }()    go func() {        for i := 0; i < 6; i++ {            time.Sleep(300 * time.Millisecond)            fmt.Println("still running", i)        }    }()    time.Sleep(3 * time.Second)}

运行结果会是:

still running 0still running 1still running 2still running 3still running 4still running 5blocking done

这个代码示例的含义是,第一个 G 明明会阻塞任务队列,一直占着 P 执行,但实际上第二个 G 仍然在运行。

说明 GMP 调度器不会因为某个 G 的阻塞,影响到其他 G 的执行。(其实这是协程调度器很基本的要求)

关闭异步抢占

对于这个代码示例:

package mainimport (    "fmt"    "runtime"    "time")func spin() {    for { /* 紧密循环 */    }}func main() {    runtime.GOMAXPROCS(1)    go spin()    time.Sleep(100 * time.Millisecond)    fmt.Println("I should still print unless preemption is off")}

可以分别用两个命令来运行,一个是

go build demo7.goGODEBUG='schedtrace=1000,scheddetail=1' ./demo7

另一种是:

go build demo7.goGODEBUG='schedtrace=1000,scheddetail=1,asyncpreemptoff=1' ./demo7

asyncpreemptoff=1 可以关闭异步抢占。也就是说,如果没有关闭,没有带这个参数,程序会正常运行,打印出:

I should still print unless preemption is off

如果关闭了异步抢占,则程序会被死循环卡住。这个例子主要可以体现 GMP 主动让出 CPU 的特点,当关闭了主动让出的能力后,GMP 就会被阻塞住了。

Go 语言源码

我没有深入看源码,比如 G、M、P 的常量定义在 src/runtime/runtime2.go 文件:

再比如 src/runtime/proc.go 文件中的 runqputslow 函数,功能就是判断本地队列有没有满,如果满了就放到全局队列:

进一步深入

这篇文章肯定有不全面和不到位的地方,我不想进一步深入了,也许有人喜欢折腾这些吧。

Go 语言的 GMP,就是协程调度器的一种具体的工程化的实现,估计很多人在意的,是这种工程化实现背后的细节,比如怎么用栈结构来管理任务队列、怎么实现抢占、让出逻辑等。协程调度器的具体实现方式可以有各种各样的变化,但它们的基本原理都是 continuation。只是 Go 语言把协程作为卖点了。只要其他语言愿意,也是可以开发出自己版本的协程调度器的。

那么问题来了,那些喜欢研究 GMP 原理的人,你们有没有了解过其他语言的协程(coroutine)、虚拟线程、异步函数、Process 是怎么实现的,它们都是比线程更轻量的类似于协程的东西,和 Go 语言的 gorountine 有什么区别?横向对比一下?

如果什么时候,我的工作需要,只有我了解这些内容,才能把工作做好,那么我肯定去把这些东西搞明白。

疑问

我之前写过一个观点:

Go 语言 “千辛万苦” 做出了自动的垃圾回收,减轻程序员对于内存管理的头脑负担。而有些面试官 “千辛万苦” 去搞明白 Go 语言 GC 的原理是什么,怎么标记怎么释放之类,不但引以为豪,而且拿来考察候选人。作为 Go 语言的教徒,你知不知道你的行为在否定 Go 语言设计者的努力?如果真的相信用头脑来管理内存的力量,为什么不去搞 Rust?好比我是一个汽车驾驶员,我要去考驾照,难道需要我搞清楚发动机的工作原理、是怎么把汽油燃烧转变为机械动力的、能量转化公式是什么?我又不是在制造汽车,也不是在开发编程语言。

同样的道理:

Go 语言为了让广大程序员能便捷简单地、用上轻量级的协程,“千辛万苦” 搞出来一个 go 关键字,然而有些人却费尽 “千辛万苦” 研究这个调度器是怎么实现的,懂原理则说明会 Go 语言,不懂则说明 Go 语言水平不行,这是什么道理?作为 Go 语言的教徒,你在否定 Go 语言设计者的努力,明白吗?如果这个语言需要你搞清楚协程调度的原理,才能写出好的代码,那就说明这个语言实现的不到位,偏离了设计者的初衷,没有达到设计者本来的意图。

如果你是编程语言的开发者,需要在另一种语言中借鉴、实现、优化 Go 语言的调度器,那么你就尽情研究吧,这样的工作确实需要懂 GMP 调度器的原理。如果不是那样的工作呢?

Web3 项目分析计划

2025-08-14 23:00:00

Web3 项目分析系列文章专用的 Paragraph 频道地址是:

计划内容

经过几天时间的尝试,我觉得 Web3 项目分析计划是一件很有意义的事情。不清楚看到分析文章的人有没有收获,但是从我自己理解项目、学习技术的角度,是有收获的,所以我需要把这个计划继续下去,变为一件常态化的事情。

具体计划内容是,每周分析一个 Web3 行业的项目,从看白皮书开始,到理解项目的运作模式、当前商业状态等,尤其关注技术理念和技术创新方面,然后写成分析报告,不需要很专业的那种报告,大概相当于学习笔记就可以了。具体分析哪个项目是经过主观挑选的。最终的分析报告也许会有质量,有看点,但也许会比较短,没有质量。因为我并不能在一开始选定项目的时候,就知道这个项目有没有含量,尤其是技术含量。

工程代码没有价值

这个计划有点像是区块链研究员干的事情,而不是区块链程序员应该干的事情。为什么我的计划不是每天写 100 行代码,开发一个区块链小工具,或者每天积累一点,开发一个大的区块链工程?

因为工程代码如果脱离项目背景,就没有价值。我在几年的工作中写过很多代码,但是如果现在把那些代码拿出来,会发现毫无意义。工程化的代码,往往是为了完善项目的功能,而项目需要某些方面功能,是为了迎合运营和宣发的需求,一定是有商业目的的。如果需求背景不存在,代码就毫无价值。

尤其是随着 AI 的日益强大,写工程代码这件事情更是越来越廉价。AI 可以几分钟写出上万行代码,堆砌代码的能力绝对超过人类。如果我想靠每天写几百行工程代码来训练和提升自己,那我一定会失败的很惨。所以不能干这样的事情。

什么样的工程代码是有意义的呢?就是已经找准了产品需求和定位的情况下,想把功能落实跑通,然后让 AI 来干活,把代码写出来。AI 写的代码有时候会跑偏,需要手动修复一下 bug,这种情况下,手动写出的工程代码才有意义。现在的开发节奏已经应该是这样了。

以前的时代,人们喜欢说 “Talk is cheap. Show me the code”,但是现在时代变了, prompt 比 code 更有价值,也许这句话会变为 “Code is cheap. Show me the prompt”。

文章更能表达思想

不去计划每天写一些工程代码,另一个原因在于,我已经做过了一些尝试,去试图开发小的区块链工具,或者大的区块链工程。目前来看,我之前的想法,无论是做小工具的思路,还是做大工程的思路,都是没有结果的,因为需求本身也许不存在。没有任何正反馈,根本做不下去。

与代码相比,写文字、写文章、写观点更有意义一点。一个产品创意背后,可能有 100 行代码,也可以有 10000 行代码,需要付出的时间成本完全不同,但如果最终的关注量都是 0,那么结果就是一样的,9900 行代码白写了。而文字是能够体现思想的。

你也许想反驳,怎么能说工程代码没有价值呢?以太坊的客户端同一份 Spec,有五六种工程化的实现,用了不同语言、做了不同优化,市场占有率有高有低,难道不是工程化代码价值的体现吗?当然是,他们拿着以太坊基金会的赞助开着公司写着代码,而且已经有了明确的项目背景,工程代码自然是有价值的。我指的是没有项目背景的工程代码。

虽然工程代码没有价值,但教学性质的代码是很有意义的,我仍然会复习计算机课的练习题,以保证自己的代码水平。我已经是第三轮做那些练习题了,这次我严格限制自己的做题速度,一天最多做一道题。一方面是保证有足够的时间消化练习题包含的知识,相信潜意识的力量。另一方面,得分配时间到其他事情上,不能整天只反复做同样的题。而且由于做题比较慢,可以逐渐培养自己每天做题的习惯,不至于遗忘计算机课的知识。

提高宏观理解能力

为什么我觉得对项目做分析是有意义的?因为其实我对区块链技术的理解,很大程度上,来自于几年前读了很多白皮书。我当时按照币种市值的排名,逐一下载了排名前几百的币种白皮书,还用 A4 纸都打印出来看。

记得几年前有人发邮件问我,如何学习区块链技术。我当时认真写了个回复,说我是从哪个网站下载的白皮书,以及看了哪些书之类。后来对方回复我说,这不是他想要学习的区块链技术,他想要学习的是如何写代码。那个时候我才意识到,不同的人,对技术的定义是不同的。

以前没有 AI,我没能认识到代码的价值,现在有了 AI,我还是认识不到代码的价值。

研究能力的重要性

在币圈,人们常说 DYOR(Do Your Own Research),这个词经常出现在 KOL 推广和夸赞某个代币的时候,用来声明不做投资建议,你要自己对自己负责。“研究能力” 一直都是非常重要的能力,如果不具备好的研究能力,你连自己的钱都管理不好。事实上什么事情都需要研究,研究如何学英语、研究如何找工作、研究假期去哪儿玩、研究写代码、研究科学技术、研究如何哄女朋友开心,等等,都是研究。Web3 项目分析计划的目标正是研究项目、锻炼研究能力。

具备好的研究能力的人,不管学习什么都会变得轻松。试想,你觉得去研究明白怎么把代码写好,尤其是工作中用的普通代码,需要多长时间?很多时候连 “研究” 都用不着!那么,你觉得能把某种技术研究明白的人,会没有能力研究清楚怎么写代码吗?

那么为什么我觉得自己可以写出分析报告?我以前没专门写过,但是有时会根据技术来对项目做横向对比,所以专注于对某个项目做技术分析,应该不是难事。我工作过的项目,假如让我写分析,肯定能写出其中的细节,只是因为项目还在,不能写。写项目分析对我来说也是一个学习和积累的过程。

实际上分析区块链项目的方法论,我早在《看懂任意区块链项目的技术架构》就写过了,到现在都不觉得那篇文章内容有什么问题,无非就是链上链下交互,不同项目往里面填充不同的业务逻辑而已。

写作平台的选择

我对于博客上应该放哪些文章,是比较纠结的,我不希望一打开博客,满屏幕都是 “对 XX 项目的分析”。为了保持文章列表的简洁,这些项目分析系列的内容应该换一个平台放。最近看到 Paragraph 不错。Paragraph 是一个 Web3 领域的 Newsletter 平台,功能类似于 Web2 的 Substack,每篇文章的全部内容都会提交到 Arweave 区块链上,包括作者的名字、头像、文章正文、配图等。(这也就意味着文章一旦发布,就不可能被删除。)

为什么不选择其他平台呢?比如发到知乎、掘金,甚至是头条、百度、登链等平台,再加上 Meidum、X、Mirror 一类,文章访问量肯定可以高很多,关注量也会高很多。

因为那些充斥着低质量内容的平台,不值得去发布高质量内容的文章。那些人是看不懂的,看不懂我在写什么。看看掘金首页上有什么?10 篇文章 8 篇讲 Cursor,很难想象用户素质得多低。知乎就更不用说了,内容杂乱、商业化,关键是网页访问弹窗,不是让登陆就是让下载 APP,正经人谁去那种平台啊。我在脉脉的职言区,匿名账号下,发布过几千条帖子,总阅读量超过几千万,发的都是观点偏激、引战一类的内容。那种阅读量有意义吗?没有意义。

所以继续努力吧,等自己成为 somebody,再考虑访问量的问题。没有人会关心 nobody 写的东西。

为什么要做出计划

其实要按照我自己喜好,我觉得自己真正有价值的文章,是吐槽同事、吐槽公司、吐槽面试经历等情绪宣泄类内容。那些是包含了亲身经历、切实体会、真情实感在里面的,耗费了时间和心情才得到的、宝贵的人生体验,比技术文章有意思多了。对行业的见解、对公司的不满、对同事的吐槽,是我的文章永远超越 AI 的地方,因为 AI 没有情绪,不会生气、不会沮丧。单纯讲技术知识点,AI 一下子就能生成很多,但是 AI 永远无法体会到作为人的情感。

反正人总要做选择,要么忙着活,要么忙着死。

对 0G 项目的分析

2025-08-06 13:08:00

首先我不是很看好 0G 的技术含量,因为 0G 是中国团队开发的项目。0G 是一个 AI 赛道的项目,3 月份在 TinTinLand 上发布过招聘信息,大概 9 月份要发币的样子,猜测在 AI 方面的噱头大于技术积累。我因为最近加了一个 TinTinLand 的学习群,和 0G 合作推出社区课程那种,所以稍微有点兴趣来分析下这个项目。

0G 的官网地址是 0g.ai,在官网上就极尽所能的把各种名词摆上了,”the next generation”、“decentralized AI”、”DeAIOS”、”RWA”,用词口径越大通常不是一个好兆头。

项目背景

0G 在 2024年8月 发布了 白皮书,单从白皮书目录和篇幅来看不是很乐观,目录结构比较简单,一共只有 20 页的内容。篇幅长度是肤浅的判断方式,比特币的白皮书也才 9 页。主要是目录结构,作为一个 AI 技术导向的项目,如此简洁的章节会给人草台的感觉。

首先来看看摘要里怎么说,0G 在解决的是 AI 模型训练过程中透明度的问题:

话说,看到 modular 这个词我有点不好的预感,尤其是看到 DA 这个词后,心想该不会用的 Celestia 吧,结合官网首页上宣称的 2500/s 的 TPS,有哪条链能做到呢?Cosmos 有点像。不过到这里还不理解首页上说的 8K 个 validator 是什么含义,Cosmos 可做不到这个。

好在不是 Celestia,白皮书里没详细说技术选型的事,但明显和 Celestia 是并列关系,自己搞了个叫 0G DA 的链。

白皮书里详细解释了 PoRA(Proof of Random Access)的挖矿机制,这个是有技术含量的部分,与 Filecoin 冷储存的模式不同,0G Storage 强调链上可以即时访问数据,所以设定了 8TB 的挖矿窗口,要求矿工可以快速在范围内验证数据完整性。

PoRA 的局限性在于,通过随机抽样验证的方式,可以验证矿工是否拥有完整数据,但是不能证明矿工拥有的数据是唯一的,也就是缺少 Filecoin 的 PoRep 提供的能力。这与网络面对的场景以及经济模型设计有关,0G Storage 只希望保证数据的可用,从矿工的奖励方式上限定了作恶是不能得到更多奖励的,所以整体机制上奏效。而 Filecoin 是根据算力高低给奖励,要面对的问题不一样。

从官网的第一篇 博客文章 中能更直观看到一些信息,0G 包含两个关键组成部分:0G Storage 和 0G DA,本质上在解决的就是 DA 的问题,主要是试图把这种 DA 能力用到 AI 场景中,所以分类到 AI 赛道了。项目背景上是一个分布式存储类的区块链项目。

0G 去年得到了 3 千万美元的种子轮融资,还是挺有资本的。

具体到工程实现上,可以看到 0G Storage 的 代码 基于 Conflux 的节点代码,在其之上做了一些功能开发:

PoRA 的工程实现部分就不深究了。

项目架构

刚才从项目背景的角度,只提到了 0G Storage 和 0G DA 两部分,除此之外,0G 这个项目还有两个角色,0G Chain 和 0G Compute Network。估计一开始的项目规划里没有,所以白皮书里没提。

0G Chain 是一个用 Cosmos SDK 开发的链节点(终于看到 Cosmos 的身影了),而且是直接用了 evmos 来兼容以太坊智能合约的做法:

0G Chain 的仓库最后一次提交代码是在 5 个月前,也许已经放弃了用 Cosmos SDK 的路线。因为有一个近期比较活跃的仓库 0g-geth,看起来是在做 Geth 的二次开发,通过集成预编译合约的方式,加入对 0G DA 的支持。

0G Compute Network 是真正和 AI 模型训练相关的部分,现在已经支持一些 预训练模型 的使用。用户层面的使用比较简单,类似于 OpenAI 的 SDK 一样,发起请求,得到响应,就是一个 Client 层的 SDK。

给 0G Compute Network 的模型提供算力的节点叫 Provider,代码仓库是 0g-serving-broker,代码仓库里有体现模型训练的代码,比如 finetune.py 这个脚本是基于 Transformer 做文本模型的微调,Docker 容器是直接基于 pytorch 2.5.1-cuda12.4-cudnn9-devel 的容器打包。

所以从 LLM 模型训练的角度看,0G 有一些工程方面的技术内容。只不过 0G 在干的事情是微调(Fine-tuning),也就是基于预训练(Pre-training)好的模型,进一步用较小的算力训练,达到执行某种特定任务的效果。而我们平时看到的 OpenAI 和 Grok 等大公司,动辄 1 TB tokens 的训练量,干的事情才是预训练。

比如 OpenAI 训练并开源出一个 GPT-3 模型(实际上没开源),那么 0G Compute Network 就是基于这个 GPT-3 模型,结合自己的语料进行一些微调,训练出一个自己版本的 GPT-3 模型。大概就是这个意思。

更准确一点说,0G Compute Network 是提供了一个训练的场地,结合了区块链相关的经济模型、奖励机制等交互,让用户可以给微调这件事情提供算力并获得收益,另一些用户可以使用微调之后的模型。

至于 Provider 与链上合约交互的部分,应该就好理解了。0G 是用 Solidity 写的合约 0g-serving-contract ,对合约的调用自然也是以太坊生态的那一套组件。而 0G 需要做的,就是把模型微调(训练)的结果,以及关于训练任务的分发、奖励记录、惩罚机制等,用合约来实现,然后在链下的算力节点上集成对合约的交互。

总结

综合来看,我需要改正一开始的态度,0G 是有一些技术含量在的,只不过更加侧重于工程方面的技术,无论是区块链方面的 DA,还是 AI 方面的模型微调,其实做的都不错,业务逻辑上已经能形成闭环。

但是说实话,写 0G 项目的分析,比之前写其他项目的分析,思路稍微不清晰一点,因为白皮书和文档都不是很完善,项目的技术路线又不是特别统一,所以没有非常好的资料自上而下的贯彻整个项目结构。不过经过以上内容的分析,我想应该已经刨析清楚了 0G 这个项目的技术情况。

continuation 教程: 理解 CPS

2025-07-23 12:12:12

这是一个 continuation 系列教程。因为篇幅问题,博客只放第 1 篇:

  1. continuation 教程:理解 CPS
  2. continuation 教程:用 yield 实现协程调度
  3. continuation 教程:用 call/cc 实现协程调度
  4. continuation 教程:用 shift/reset 实现协程调度
  5. continuation 教程:体验 Racket 语言
  6. continuation 教程:实现抢占式协程调度

我们来由浅入深地系统学习下 continuation 的原理以及应用场景。这个系列教程的内容和王垠的 continuation 专项班无关,是我自己学习和研究的成果,所以不会有版权问题。不过当然正是因为我学习了基础班,打下了坚实的基础,才知道该如何去自学和理解 continuation 这个概念。这篇文章会少量透露出基础班学到的技能,毕竟 continuation 属于基础班的进阶内容,无法跳过基础技能去理解。

递归

首先用递归的形式写一个阶乘函数 fact,我们已经很熟悉它的写法,不需要过多解释:

function fact(n){  if (n === 0)   {    return 1;  }  else  {    return n * fact(n - 1);  }}console.log("fact1=", fact(1)); // 1console.log("fact3=", fact(3)); // 6console.log("fact5=", fact(5)); // 120

尾递归

接着把 fact 函数改为尾递归的形式。尾递归会比递归多一个参数,新参数用来保存每个调用计算后的值:

function factTail(n, prod){  if (n == 0)  {    return prod;  }  else  {    return factTail(n-1, prod*n);  }}console.log("factTail1=", factTail(1, 1)); // 1console.log("factTail3=", factTail(3, 1)); // 6console.log("factTail5=", factTail(5, 1)); // 120

CPS 形式

我们基于 fact 函数的尾递归形式,再新增一个参数 k,这个 k 是一个函数,fact 不直接返回计算后的值,而是结果值对 k 函数的调用,像这样:

function factTailCPS(n, prod, k){  if (n == 0)  {    return k(prod);  }  else  {    return factTailCPS(n-1, prod*n, k);  }}factTailCPS( 1, 1, x => console.log("factTailCPS1=", x) ); // 1factTailCPS( 3, 1, x => console.log("factTailCPS1=", x) ); // 6factTailCPS( 5, 1, x => console.log("factTailCPS1=", x) ); // 120

这个 k 就是 continuation,意味着告诉 fact 函数,你执行完了计算出结果之后,应该如何进行下一步延续。不用怀疑,这个函数完全符合 CPS(Continuation-Passing-Style)的形式。

典型 CPS

但是用尾递归结合 continuation 参数的形式,显然不够简洁,并不算典型的 CPS 形式。典型的 CPS 形式比较难理解,所以不需要自己思考出来,直接看这个现成的例子,我们对递归形式的 fact 函数改进一下:

function factCPS(n, k){  if (n == 0)  {    return k(1);  }  else  {    return factCPS(n-1, r => k(n * r));  }}

可能看着有点懵,不要慌,我们拆解一下其中的内容。首先 k 仍然代表 continuation,并且 k 是一个函数。然后我们这样来调用:

let factCPS1 = factCPS(0, x => x);console.log("factCPS1=", factCPS1); // 1let factCPS3 = factCPS(3, x => x);console.log("factCPS3=", factCPS3); // 6let factCPS5 = factCPS(5, x => x);console.log("factCPS5=", factCPS5); // 120

关键在于调用的时候,传入函数的第二个参数是 x => x,如果结合函数内部的 r => k(n * r),也许一下子就糊涂了。

这确实是最难理解的部分。我们以计算 2 的阶乘为例,写一个拆解 factCPS 函数调用步骤的过程。这里用到的技巧是在基础班第一课就学过的 单步替换,对于理解递归非常有帮助。如果在基础班经过训练并且打好基础,确实会有助于理解更复杂的东西,比如这里的 CPS 调用:

let factCPS2 = factCPS(2, x => x);console.log("factCPS2=", factCPS2); // 2// n=2, k=x=>x, return factCPS(1, r => k(2 * r));  // n=1, k=r=>(x=>x)(2*r), return factCPS(0, r => k(1 * r));    // n=0, k=r=>(r=>(x=>x)(2*r)(1*r)), return k(1);      // k(1) = r=>(x=>x)(2*r)(1*1)      //      = (x=>x)(2)      //      = 2

虽然我已经按照正确的思路拆解出了正确的步骤,但是从阅读者的角度,这仍然会非常难理解,可以自己拆解一下试试,逐步理解典型 CPS 的调用过程。理解这些步骤也许需要几个小时的时间,这是正常的。

总结来说,CPS 的每一次调用,都是在用闭包来储存当前步骤计算的值。尾递归是直接用参数传递值,而 CPS 是在用闭包传递给下个步骤值,就是这样的关系。当然理解这一点的前提是,知道闭包是什么,这个也是基础班学习的重点内容,尤其是会在实现解释器环节,自己实现闭包的语句,对于闭包的理解会很透彻。

fib 函数的 CPS

计算阶乘的函数 fact 特点是只在函数体内进行一次递归调用,我们再来看计算斐波那契数列的 fib 函数,它会在函数体内进行两次递归调用,CPS 该怎么处理这个情况。

fib 函数的递归形式的定义是这样:

function fib(n){  if (n == 0)  {    return 0;  }  else if (n == 1)  {    return 1;  }  else   {    return fib(n-1) + fib(n-2);  }}console.log("fib(2)=", fib(2)); // 1console.log("fib(5)=", fib(5)); // 5

这里直接给出 fib 函数的 CPS,然后理解一下 fib 函数的运作过程:

function fibCPS(n, k){  if (n == 0)  {    return k(0);  }  else if (n == 1)  {    return k(1);  }  else  {    return fibCPS(n-1, r1 => fibCPS(n-2, r2=>k(r1+r2)) );  }}

可以看到,对于需要两次递归调用的情况,CPS 是把另一次递归调用,写在了原本的 r => k(r) 函数里,让第二次内部调用成为了递归调用 fib 时候的子调用。这句话有点绕,可以结合代码理解一下。

CPS 形式的 fib 函数这样来调用:

let fibCPS1 = fibCPS(1, x=>x);console.log("fibCPS1=", fibCPS1); // 1let fibCPS2 = fibCPS(2, x=>x);console.log("fibCPS2=", fibCPS2); // 1let fibCPS4 = fibCPS(4, x=>x);console.log("fibCPS4=", fibCPS4); // 3let fibCPS5 = fibCPS(5, x=>x);console.log("fibCPS5=", fibCPS5); // 5

我们以计算 3 的斐波那契数为例,拆解一下具体的执行步骤。要注意的是,这个过程非常复杂,比 fact 函数还要复杂很多,只有自己亲自写一下才能搞清楚:

let fibCPS3 = fibCPS(3, x=>x);console.log("fibCPS3=", fibCPS3); // 1+1=2// n=3, k=x=>x,        // return fibCPS(2, r1 => fibCPS(1, r2=>(x=>x)(r1+r2)) );// n=2, k= r1 => fibCPS(1, r2=>(x=>x)(r1+r2)),        // return fibCPS(1, r1 => fibCPS(0, r2 => ( r1 => fibCPS(1, r2=>(x=>x)(r1+r2)) )(r1+r2)) );// n=1, k= r1 => fibCPS(0, r2 => ( r1 => fibCPS(1, r2=>(x=>x)(r1+r2)) )(r1+r2)),        // return k(1)       // return ( r1 => fibCPS(0, r2 => ( r1 => fibCPS(1, r2=>(x=>x)(r1+r2)) )(r1+r2)) )(1)       // return fibCPS(0, r2 => ( r1 => fibCPS(1, r2=>(x=>x)(r1+r2)) )(1+r2))          // n=0, k= r2 => ( r1 => fibCPS(1, r2=>(x=>x)(r1+r2)) )(1+r2)              // return k(0)              // return ( r2 => ( r1 => fibCPS(1, r2=>(x=>x)(r1+r2)) )(1+r2) )(0)              // return ( r1 => fibCPS(1, r2=>(x=>x)(r1+r2)) ) (1+0)              // return fibCPS(1, r2=>(x=>x)(1+r2))                  // n=1, k = r2=>(x=>x)(1+r2)                  // return k(1)                  // return (x=>x)(1+1)                  // return 2

那么经过了 factfib 函数的训练,我们就已经知道 CPS 的形式是什么,以及具体的执行步骤是怎样了。理解 CPS 只是开始,接下来还会利用 continuation 实现更多有趣的程序。

练习题

已知一个递归形式的 sumFrom 函数,接收两个参数 ab,函数的功能是计算 a+(a+1)+...+(b-1)+b 的值,例如参数是 14,则计算 1+2+3+4 的结果:

function sumFrom(a, b){  if (a == b)   {    return a;  }  else  {    return b + sumFrom(a, b-1);  }}console.log(sumFrom(1, 3));   // 6console.log(sumFrom(2, 5));   // 14

练习的内容是,将 sumFrom 函数修改为 CPS 形式,补充 sumFromCPS 函数空白处的代码,让程序可以满足测试用例中的输出结果:

function sumFromCPS(a, b, k){  // ____}sumFromCPS(1, 3, x => console.log(x));   // 6sumFromCPS(2, 5, x => console.log(x));   // 14

延伸阅读

我们已经体验了手动将递归程序转变为 CPS 形式的过程,实际上存在能将代码自动转变为 CPS 形式的方法,也就是传说中 “王垠 40 行代码” 在干的事情。可以参考这两个链接查看更多内容:

因为 “自动 CPS 变换” 的难度比较大,我自己不打算学习和实现这个。