2025-09-06 00:30:00
在对 Arc 项目 进行分析的过程中,发现 Arc 干了一件很有意思的事情,先是自己开发了 Rust 版本的 Tendermint 共识 malachite,接着开发了一个对接 Reth 和 malachite 的兼容层 malaketh-layered,也就是说,Arc 这条链的架构是这样:
Reth -> malaketh-layered -> malachite
最终形成了一条完全以太坊等价的 PBFT 链。
那么有没有类似架构的链,直接把 Geth 和 CometBFT 给结合起来呢。是有的,Berachain 开发了一个beacon-kit,干的就是这样的事情,Berachain 主网本身就是这种架构启动的。
但是 beacon-kit 有一个问题,就是代码过度 “复杂”,不但自己设计了 slot 的概念,还把 Berachain 的一些经济模型的设计、LST 质押之类的东西都放到了 beacon-kit 中。所以虽然 beacon-kit 在工程上是一个 Geth+CometBFT 可行的实践,但是它本身并不是工具性质的立场在做,夹带了不少私货。
因此我觉得需要一个通用的、工具性质的兼容层项目,目前命名为 EthBFT。这个项目的愿景是,提供简洁、开放、最小实现、工具性质的架构,达到集成 Geth 和 CometBFT 的目的。整个区块链网络的架构会是这样:
Geth -> EthBFT -> CometBFT
EthBFT 主要干两件事情:
这里虽然用 Geth 举例,但对于其他以太坊的执行层客户端,应该也是通用的,因为以太坊的执行层和共识层客户端,本来就是互相兼容的,仅仅通过 RPC 接口通信。所以预计 EthBFT 可以兼容全部的以太坊执行层客户端。
而 EthBFT 的设计,自然不会和 Geth 或者 CometBFT 有代码层面的耦合,EthBFT 是一个独立的进程,可以单独启动,Geth 也可以单独启动,CometBFT 也可以单独启动,3 个组件之间,彼此通过 RPC 接口通信,具体的 RPC 接口地址等信息则会体现在 EthBFT 的配置文件中。
这就让 3 个组件互相之间,完全解耦了。
我之前以为区块链技术的发展会趋于追新,也会趋于去中心化,但是发现似乎不是那样。
从前两年的 Celestia 使用了 PBFT,到 Hyperliquid 改进了 PBFT 共识,再到最近 Arc 项目自己实现了 PBFT 共识,证明在高TPS的场景下,PBFT算法还非常有活力。
PoW 和 PoS 去中心化程度高,但是不能满足高 TPS 的需求,也不能达到最终一致性的要求,这些都是 PBFT 特有的优势,尤其是企业级的应用场景下,没那么在意去中心化。
我们也许会有疑问,如果不在乎去中心化,那直接用 Server 端提供服务不就行了吗,用区块链干什么。在丢失去中心化特性的前提下,至少区块链还保留有数据公开、数据变更可追溯等特点,也是一些不错的优势。
因此,PBFT 这种诞生接近 30 年的算法,将来还会继续发光发热。也因此,去搞一个 PBFT 相关的项目,不会有太大问题。
EthBFT 肯定不会受到市场的关注,因为大家只在乎一条链能不能发币,能不能套利,并不在乎你的技术架构是什么。
EthBFT 只是一个工具性质的项目。如果一个开发者,想要一条以太坊完备的链,同时又想要高 TPS,在没有 EthBFT 的情况下,需要怎么做呢。我懒得展开分析对比搭建链的方案了,总之我觉得 EthBFT 可以填补这部分的空缺,非侵入式那种。世界上缺一个这样的工具。
现在 smallyunet/EthBFT 项目已经有了基本的框架,能跑通最小版本,我把它归档为 v0.0.1 版本。能跑通的表现是 Geth 的区块高度会逐渐增加,CometBFT 也在正常出块,Geth 和 CometBFT 的区块高度保持同步。当然现在还属于非常早期的版本,开发时间有限,功能上肯定有不完善的地方,接下来还会继续改进。
我之前说 鼓吹 Cursor 的人技术能力都差,因为 AI 可以放大你的能力,但是不可能代替你懂。v0.0.1 版本的 EthBFT,全部代码都是 AI 写的,没错,但是以 EthBFT 这个项目为例,现在要干的事情非常清晰,你可以试试,在不懂以太坊和 Cosmos,甚至不懂技术的情况下,完全托管给 AI,能不能搞出一个能运行的、EthBFT 这样的项目。
如果你自己对技术的理解不清晰,或者有错误,关键是 AI 不会纠正你的错误,因为 AI 并不知道你心里想要的 “正确” 是什么。AI 会非常听话地按照你的描述写代码,如果你语焉不详,AI 写出来的代码必然会跑偏,朝着错误的方向发展,而且很多时候 AI 会自己偷偷埋坑,你以为它实现了,结果它要么没写全,放了个 TODO 在那儿,要么按照自己的理解写出一大堆不需要的代码。
所以让 AI 把代码写对,其实不是一件容易的事情,首先你自己得懂,然后你得时刻盯着它干活。AI 始终只是助手而已。
2025-08-25 01:05:32
设想一下,假如你不想学王垠的课,但是又想掌握课程中的知识,有哪些途径?
你花同样的钱,是没有其他地方可买的。世界上还有其他华人,能有王垠的学识背景,并且在经过几年的教学试验后,整理出如此精品的课程吗?这种水平的课程,有市无价!
你也可以自己去求学,先掌握流利的英语,然后考个美国顶尖大学的硕士,经过几年的留学生涯,不但花很多钱,还要付出许多时间、精力和努力,还需要一些天赋和运气,才能学到与王垠课程同等水平的知识。你付出的代价,远远不是只花钱就够的。
你还觉得王垠的课程贵吗?
我刚才对王垠课程的描述是 “这种水平的课程”,那到底是什么水平呢?我可以举个具体的例子。
课程的第 1 课讲函数,对吧,所有程序员都知道函数是什么,即使不是程序员,初中上数学课也知道函数。
学习课程以前,函数是什么?函数是编程语言的语法之一,作用是把很多行代码包裹起来,方便以后重复调用。面向对象里面叫 “封装”。函数就是个特别基础的概念。
学习课程以后,函数是什么?函数可以是 “计算” 的基本元素,函数可以作为计算的输入,也可以作为计算的输出,一个计算的输出可以作为另一个计算的输入,输出的函数可以被另外的函数调用……
我这么说你肯定没看明白。
换个角度解释,王垠的导师 Daniel P. Friedman,有一本出版的书《The Little Learner》,Guy L. Steele Jr. 在给这本书的序言中写到,Friedman 在这本书里用高阶函数(higher-order functions)的 4 种不同用途,表达了机器学习(machine learning)的核心原理。
你是不是不相信,函数可以表达深度学习的原理?那就去了解一下 lambda calculus,一种和图灵机同等地位的形式化系统,可以表达的是整个计算机体系,而不只是深度学习。
这才是 “函数”。第 1 课学的函数,是这个函数。
2025-08-20 22:00:00
这是一个 DeFi 系列教程,在动手实践的过程中,学习和理解 DeFi 相关的概念与原理。因为篇幅问题,博客只放第 1 篇:
- DeFi 基础: 理解 AMM 定价机制
- DeFi 基础: 预言机与报价
- DeFi 基础: 借贷与清算
- DeFi 进阶: 闪电贷与套利
AMM 的全称是 Automated Market Maker,自动做市商,作用是不需要订单簿撮合交易,就可以自动完成定价与交易。
这篇文章解释了 Uniswap V2 的核心定价逻辑,并且提供了完整的合约代码示例、命令行操作步骤、实际的链上交易现场等,作为理解 AMM 的配套参考。
Uniswap V2 用的定价逻辑是恒定乘积做市商(Constant Product Market Maker, CPMM),也是我们的示例 AMM 合约在用的方法。这里有一个恒等公式:
x * y = k
意味着池子里有两种资产 x
和 y
,当 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
这里的参数 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
声明钱包地址与合约地址:
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 添加流动性,添加流动性会调用 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
添加流动性的 函数 比较简单,大概是合约里有两个变量 reserve0
和 reserve1
,调用 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]
我们的合约代码 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。
我们来实际发起交易,看看合约运行后的效果,先试着用 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 的价格会越来越高。
再来用 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 真的减少了。
可以自己测试玩一下。
2025-08-18 21:50:50
声明:我看不起 “Go 语言 GMP 调度器的原理是什么” 这种技术话题。
我平时没兴趣研究这种问题。因为在面试中被问到的频率太高了,现在想花 2 个小时的时间来了解下。一方面研究下这个问题背后到底有多大的技术含量,另一方面把这个问题的答案写下来。但是我不会让这种内容停留在我的头脑里,所以下次面试被问到,我肯定还说不会 😏
GMP 是一个缩写:
go
一个,G 的数量就多一个GOMAXPROCS
,通常默认是 CPU 核心数。GMP 的意思是,启动多少个 M(线程) 来执行 G(协程),最多允许 P(核心数)个 M 并行执行。
无聊的(简化后的)定义来了:
这几句话看着很费劲,不需要现在理解,接下来会用一些代码例子来说明他们的含义。
这是一个最简单的代码文件,用来演示启动一个协程:
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)。P1
被 M2
拿着运行P0
被 M3
拿着处于 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)}
这个代码的运行结果是,有时候 A
在 B
前面,有时候 B
在 A
前面。
我们已经用 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 调度器中交替执行。
来看这个代码示例:
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 想找活干的时候,上面的代码是偷其他 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 执行任务的逻辑。
看这个代码例子:
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 就会被阻塞住了。
我没有深入看源码,比如 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 调度器的原理。如果不是那样的工作呢?
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 永远无法体会到作为人的情感。
反正人总要做选择,要么忙着活,要么忙着死。
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 这个项目的技术情况。