Compare commits
No commits in common. "main" and "v20250107" have entirely different histories.
@ -26,7 +26,7 @@ jobs:
|
||||
subject: 博客部署启动
|
||||
to: 3145078758@qq.com
|
||||
from: Gitea Actions
|
||||
body: 博客自动化部署开始执行, 前往 ${{ gitea.server_url }}/Linloir/blog/actions/runs/${{ gitea.run_number }} 查看执行日志
|
||||
body: 博客自动化部署开始执行, 前往 ${{ gitea.server_url }}/Linloir/blog/actions/runs/${{ gitea.run_id }} 查看执行日志
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
@ -60,7 +60,7 @@ jobs:
|
||||
commit_options: "--no-verify"
|
||||
token: "${{ secrets.TOKEN }}"
|
||||
- name: Call API to Update Caddy
|
||||
run: curl https://upd.linloir.cn/update/blog.linloir.cn
|
||||
run: curl https://upd.linloir.cn/update/blog.linloir.gitea.linloir.cn
|
||||
- name: Send mail On Success
|
||||
if: success()
|
||||
uses: https://github.com/dawidd6/action-send-mail@v3
|
||||
@ -86,4 +86,4 @@ jobs:
|
||||
subject: 博客部署失败
|
||||
to: 3145078758@qq.com
|
||||
from: Gitea Actions
|
||||
body: 博客自动化部署执行失败, 前往 ${{ gitea.server_url }}/Linloir/blog/actions/runs/${{ gitea.run_number }} 查看执行日志
|
||||
body: 博客自动化部署执行失败, 前往 ${{ gitea.server_url }}/Linloir/blog/actions/runs/${{ gitea.run_id }} 查看执行日志
|
||||
|
||||
@ -1,773 +0,0 @@
|
||||
---
|
||||
title: Coroutine 相关疑惑大模型问答记录
|
||||
date: 2025-03-17 21:09:21
|
||||
tags:
|
||||
- 后端
|
||||
- 编程语言
|
||||
categories:
|
||||
- 技术
|
||||
---
|
||||
|
||||
近日在看 python 的 asyncio 时突然想起了曾经对 C++ coroutine 以及 go 的 goroutine 的诸多未解疑惑。还记得当时有这些疑惑的时候,还没有 ChatGPT 或是 DeepSeek 这样方便的大模型解惑,反复查找各种资料最后因为时间原因还是没有深究,而如今 DeepSeek 已然如此发达,遂借此机会解答遗留在心中多年的疑惑,并以此贴记录。
|
||||
|
||||
## Round 1
|
||||
|
||||
**Q:**
|
||||
AsyncIO 或者说,Coroutine 是怎么实现,例如在 await 一个网络请求的时候,进程不会被内核挂起进入等待状态,而是可以继续正常被调度并且执行余下内容的?同时,当网络请求完成的时候,内核又是怎么让进程知道请求完成从而继续 await 后续未执行的内容的
|
||||
|
||||
**A:**
|
||||
|
||||
> 嗯,用户问的是AsyncIO或者说协程是怎么实现的,特别是在await一个网络请求的时候,进程不会被内核挂起,而是可以继续执行其他任务。然后当网络请求完成后,内核又是如何通知进程继续执行的。我需要详细解释AsyncIO和协程的工作原理,特别是事件循环、非阻塞I/O以及操作系统层面的机制,比如epoll或者kqueue。
|
||||
>
|
||||
> 首先,用户可能已经了解一些基本的异步编程概念,但想深入理解底层机制。需要分步骤解释,从协程、事件循环到非阻塞I/O和操作系统接口。可能需要提到用户态和内核态的区别,以及如何通过事件驱动的方式处理I/O操作,避免阻塞。
|
||||
>
|
||||
> 然后,用户可能对内核如何通知事件完成感兴趣,这里需要解释I/O多路复用机制,比如select、poll、epoll,特别是epoll在Linux中的工作方式。当有I/O事件发生时,内核如何通知应用程序,从而触发回调函数,恢复协程执行。
|
||||
>
|
||||
> 另外,需要区分进程、线程和协程的不同。协程是用户态的轻量级线程,由事件循环调度,而不是由操作系统内核调度。这样在遇到I/O操作时,可以挂起当前协程,执行其他任务,提高并发性能。
|
||||
>
|
||||
> 可能还需要提到Future和Task对象,在AsyncIO中如何管理异步操作的状态,以及await关键字如何挂起协程,让出控制权给事件循环,直到Future完成。
|
||||
>
|
||||
> 最后,总结整个流程,从发起网络请求到事件循环注册,到操作系统处理I/O,完成后的回调,恢复协程执行。确保解释清晰,技术细节准确,同时避免过于晦涩。
|
||||
|
||||
AsyncIO/Coroutine 的实现核心在于**用户态协作式调度**和**操作系统非阻塞I/O机制**的结合。以下是技术实现的关键点:
|
||||
|
||||
---
|
||||
|
||||
### 一、核心机制
|
||||
1. **事件循环 (Event Loop)**
|
||||
通过单线程轮询I/O事件,使用`epoll`(Linux)/`kqueue`(BSD)/`IOCP`(Windows)等系统调用实现高效事件监听:
|
||||
```python
|
||||
# 伪代码示例
|
||||
while True:
|
||||
events = epoll_wait(epoll_fd, timeout)
|
||||
for fd, event in events:
|
||||
callback = fd_to_callback[fd]
|
||||
callback()
|
||||
```
|
||||
|
||||
2. **非阻塞I/O**
|
||||
通过`fcntl(fd, F_SETFL, O_NONBLOCK)`设置socket为非阻塞模式,当I/O未就绪时立即返回`EWOULDBLOCK`错误,避免进程被挂起。
|
||||
|
||||
3. **协程调度**
|
||||
使用生成器(Python 3.5+的`async/await`)实现协程切换:
|
||||
```python
|
||||
async def fetch():
|
||||
await socket.read() # 让出控制权
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 二、内核交互细节
|
||||
1. **I/O准备阶段**
|
||||
```mermaid
|
||||
graph LR
|
||||
A[发起socket.read()] --> B[注册到epoll]
|
||||
B --> C[挂起当前协程]
|
||||
C --> D[事件循环继续运行其他协程]
|
||||
```
|
||||
|
||||
2. **内核通知机制**
|
||||
当数据到达时,网卡通过**中断**通知内核,内核将socket标记为就绪,epoll会检测到该事件并通知用户态。
|
||||
|
||||
3. **回调触发**
|
||||
事件循环收到内核通知后,通过`callback()`恢复对应协程的上下文继续执行。
|
||||
|
||||
---
|
||||
|
||||
### 三、性能关键指标
|
||||
1. **上下文切换成本**
|
||||
| 类型 | 切换成本 |
|
||||
|------------|---------------|
|
||||
| 进程切换 | ~1-10μs |
|
||||
| 线程切换 | ~0.5-2μs |
|
||||
| 协程切换 | ~0.1-0.3μs |
|
||||
|
||||
2. **I/O吞吐量对比**
|
||||
```math
|
||||
\text{Throughput} = \frac{\text{Requests}}{\text{Context Switch Time} + \text{I/O Latency}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 四、操作系统支持
|
||||
1. **Linux epoll**
|
||||
使用红黑树管理文件描述符,时间复杂度为$O(1)$的事件通知。
|
||||
|
||||
2. **BSD kqueue**
|
||||
支持更多事件类型过滤,但API更复杂。
|
||||
|
||||
3. **Windows IOCP**
|
||||
真正的异步I/O模型,与Overlapped I/O配合使用。
|
||||
|
||||
---
|
||||
|
||||
### 五、编程模型对比
|
||||
| 特性 | 多进程 | 多线程 | 协程 |
|
||||
|---------------|--------|--------|----------|
|
||||
| 内存占用 | 高 | 中 | 低 |
|
||||
| 切换成本 | 高 | 中 | 极低 |
|
||||
| 并发能力 | 低 | 中 | 高 |
|
||||
| 数据共享 | 困难 | 需同步 | 天然共享 |
|
||||
|
||||
---
|
||||
|
||||
### 六、典型工作流程
|
||||
1. 协程调用`await socket.read()`
|
||||
2. 事件循环将socket注册到epoll
|
||||
3. 当前协程挂起,保存上下文(PC/寄存器/栈)
|
||||
4. 事件循环继续执行其他协程
|
||||
5. 内核通知数据到达,epoll返回就绪事件
|
||||
6. 事件循环调用注册的回调函数
|
||||
7. 恢复挂起协程的上下文,继续执行
|
||||
|
||||
这种设计使得单线程即可实现数万并发连接(如Nginx的C10K解决方案),同时避免了内核态/用户态频繁切换的开销。
|
||||
|
||||
## Round 2
|
||||
|
||||
**Q:**
|
||||
协程是否需要保存当前栈帧
|
||||
|
||||
**A:**
|
||||
> 好的,用户问协程是否需要保存当前栈帧。我需要先回顾之前关于协程和AsyncIO的讨论,确保回答的一致性。用户可能已经了解协程的基本原理,现在想深入技术细节。
|
||||
>
|
||||
> 首先,协程的挂起和恢复确实需要保存栈帧。因为协程在用户态切换,没有内核的调度,所以必须手动保存和恢复上下文。栈帧保存了局部变量、函数调用链等信息,这对恢复执行至关重要。
|
||||
>
|
||||
> 接下来,我需要解释栈帧保存的具体内容,比如寄存器状态、局部变量、返回地址等。可能需要用例子说明,比如Python的生成器如何保存状态。同时,要区分不同语言的实现,比如Go的goroutine使用分段栈,而Python的协程可能依赖生成器。
|
||||
>
|
||||
> 用户可能还想知道为什么需要保存这些信息,以及不保存会有什么后果。比如,如果栈帧不保存,恢复时无法继续执行,导致数据错误或崩溃。此外,栈的动态增长如何处理,比如是否使用协程私有栈,避免栈溢出影响其他协程。
|
||||
>
|
||||
> 还要考虑性能方面,栈帧保存的开销如何,比如对比线程的上下文切换。可能提到协程切换的高效性,因为只在用户态操作,无需陷入内核。
|
||||
>
|
||||
> 另外,用户可能对具体实现机制感兴趣,比如setjmp/longjmp、ucontext库或者汇编手动切换上下文。不同语言和库的实现方式不同,但核心都是保存和恢复栈帧。
|
||||
>
|
||||
> 最后,需要总结栈帧保存的必要性,强调这是协程能够正确挂起和恢复的关键,确保用户理解其重要性。
|
||||
|
||||
|
||||
|
||||
协程的实现**必须完整保存当前栈帧**,这是实现正确挂起/恢复的核心机制。栈帧保存的完整性和实现方式直接决定了协程的性能和可靠性,具体技术细节如下:
|
||||
|
||||
---
|
||||
|
||||
### 一、栈帧保存的必要性
|
||||
| 组件 | 必须保存的原因 | 丢失后果 |
|
||||
|-----------------|---------------------------------|----------------------------|
|
||||
| 返回地址 | 确定恢复执行的位置 | 程序计数器错乱导致崩溃 |
|
||||
| 局部变量 | 维护函数调用上下文 | 数据不一致或计算错误 |
|
||||
| 寄存器状态 | 保证指令连续执行 | 寄存器污染导致未定义行为 |
|
||||
| 调用链信息 | 维护协程嵌套调用关系 | 栈回溯失败,调试信息丢失 |
|
||||
|
||||
---
|
||||
|
||||
### 二、栈帧保存的实现方式
|
||||
#### 1. 轻量级方案(Python等解释型语言)
|
||||
```python
|
||||
# 生成器协程的栈帧保存
|
||||
def coro():
|
||||
x = 42
|
||||
yield # 此处保存栈帧:
|
||||
# - 局部变量x
|
||||
# - 指令指针(IP)
|
||||
# - 当前栈指针(SP)
|
||||
print(x)
|
||||
|
||||
gen = coro()
|
||||
next(gen) # 触发栈帧保存
|
||||
```
|
||||
**特点**:
|
||||
- 通过代码对象(__code__)和帧对象(f_lasti)记录状态
|
||||
- 每个yield消耗约200字节内存
|
||||
|
||||
#### 2. 高性能方案(C/C++/Rust)
|
||||
```cpp
|
||||
// 使用ucontext库手动切换
|
||||
void coroutine_entry(void* arg) {
|
||||
while (true) {
|
||||
// 业务逻辑
|
||||
swapcontext(&ctx_current, &ctx_scheduler);
|
||||
}
|
||||
}
|
||||
|
||||
// 上下文切换时自动保存:
|
||||
// - 所有通用寄存器 (rax, rbx...)
|
||||
// - 浮点寄存器 (xmm0-15)
|
||||
// - 栈指针 (rsp)
|
||||
// - 指令指针 (rip)
|
||||
// - 标志寄存器 (rflags)
|
||||
```
|
||||
**性能指标**:
|
||||
- 单次切换约23ns (x86) / 35ns (ARM)
|
||||
- 内存开销约2KB/协程
|
||||
|
||||
#### 3. 零拷贝方案(Go goroutine)
|
||||
```go
|
||||
// Go使用分段栈实现协程
|
||||
func coro() {
|
||||
var big [8192]byte // 触发栈扩容
|
||||
// ...
|
||||
}
|
||||
|
||||
// 运行时自动管理:
|
||||
// - 栈扩容时复制到新内存区域
|
||||
// - 使用连续栈时维护拷贝指针
|
||||
```
|
||||
**优势**:
|
||||
- 栈空间自动增长,无最大限制
|
||||
- 栈切换仅需修改寄存器,无需内存拷贝
|
||||
|
||||
---
|
||||
|
||||
### 三、栈帧保存的底层原理
|
||||
#### 1. 寄存器级保存
|
||||
```asm
|
||||
; x86-64上下文保存示例
|
||||
swapcontext:
|
||||
push rbp
|
||||
mov rbp, rsp
|
||||
push rbx
|
||||
push r12
|
||||
push r13
|
||||
push r14
|
||||
push r15
|
||||
mov [rdi + UC_MCONTEXT_GREGS + REG_RSP], rsp
|
||||
mov [rdi + UC_MCONTEXT_GREGS + REG_RIP], rcx
|
||||
; 共保存23个寄存器和浮点状态
|
||||
```
|
||||
|
||||
#### 2. 栈内存管理
|
||||
```c
|
||||
// 协程私有栈实现
|
||||
struct coroutine {
|
||||
void* stack; // 独立栈空间
|
||||
size_t stack_size;
|
||||
jmp_buf env; // 保存的寄存器集合
|
||||
};
|
||||
|
||||
// 切换时重置栈指针
|
||||
void switch_to(struct coroutine* from, struct coroutine* to) {
|
||||
_switch(&from->env, &to->env);
|
||||
// 汇编实现栈指针切换:
|
||||
// mov rsp, [to->env + REG_RSP]
|
||||
// ret
|
||||
```
|
||||
|
||||
#### 3. 栈溢出防护
|
||||
```rust
|
||||
// Rust async-std的栈保护机制
|
||||
async fn deep_recursion() {
|
||||
let guard = PageGuard::new(); // 分配保护页
|
||||
recursive_call().await;
|
||||
}
|
||||
|
||||
// 内存布局:
|
||||
// [ 可用栈空间 | RED ZONE (不可访问) | 保护页 ]
|
||||
// 访问保护页触发SIGSEGV,自动扩容栈
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 四、不同语言的实现对比
|
||||
| 语言 | 栈帧保存方式 | 切换开销 | 最大协程数 (8GB内存) |
|
||||
|------------|----------------------------|----------|----------------------|
|
||||
| Python | 生成器帧对象 | 120ns | ~500,000 |
|
||||
| Go | 分段栈/连续栈 | 35ns | ~10,000,000 |
|
||||
| C++ | ucontext/手动汇编 | 23ns | ~5,000,000 |
|
||||
| Java(Loom) | 虚拟线程栈镜像 | 45ns | ~2,000,000 |
|
||||
| Rust | async/await状态机编译时展开 | 18ns | ~8,000,000 |
|
||||
|
||||
---
|
||||
|
||||
### 五、特殊场景处理
|
||||
1. **动态栈扩展**
|
||||
当协程调用深度递归时:
|
||||
```c
|
||||
void coro() {
|
||||
char buf[1024];
|
||||
await read(); // 栈空间不足时:
|
||||
// 1. 分配新栈
|
||||
// 2. 拷贝旧栈数据
|
||||
// 3. 修改栈指针
|
||||
}
|
||||
```
|
||||
|
||||
2. **跨栈引用保护**
|
||||
C++20协程要求:
|
||||
```cpp
|
||||
task<void> unsafe_coro() {
|
||||
int local = 42;
|
||||
co_await something(); // 挂起点之后
|
||||
print(local); // 可能引用已释放栈!
|
||||
}
|
||||
// 正确做法:使用shared_ptr持有跨栈数据
|
||||
```
|
||||
|
||||
3. **垃圾回收协同**
|
||||
Go的逃逸分析确保协程栈安全:
|
||||
```go
|
||||
func coro() {
|
||||
x := 42
|
||||
go func() {
|
||||
println(x) // x逃逸到堆
|
||||
}()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 结论
|
||||
协程必须完整保存当前栈帧,这是实现正确执行流控制的基石。现代运行时通过寄存器快照、独立栈空间、智能内存管理等技术,在保证安全性的同时将切换开销控制在纳秒级。不同语言根据自身特性选择最优实现策略,如Go的分段栈、Rust的零成本抽象等,最终目标都是在用户态实现接近线程的编程模型,同时获得更高的并发性能。
|
||||
|
||||
## Round 3
|
||||
|
||||
**Q:**
|
||||
我好奇的一点是:如果函数 `A` 中调用了 `b = B()`,而 `B` 中调用了 `await post_http_request()`,我理解在 `post_http_request` 中会在发送完 http 请求后对 `post_http_request` 函数的栈帧内所有内容进行保存,然后返回到 `B` 中,由于 `B` 中调用了 `await`,则会保存 B 的栈帧,最后返回到 `A` 中继续执行,对吗?
|
||||
|
||||
我的疑问在于,在你前面给出的例子中,只保存了寄存器相关的值而没有保存栈上存储的内容,如果在这种情况下,`A` 函数继续执行,可能会覆盖原先 `B` 和 `post_http_request` 栈上的内容,这是否会导致切换失败?还是说,栈帧保存也会保存栈上的数据?这样不会消耗额外的时间来拷贝内存吗?
|
||||
|
||||
**A:**
|
||||
> 嗯,用户的问题是关于协程在挂起时如何保存栈帧的。他举了一个例子,函数A调用B,B中调用了await post_http_request()。用户想知道,当post_http_request发送完HTTP请求后,是否会保存它的栈帧,然后返回到B,再保存B的栈帧,最后回到A继续执行。用户担心如果只保存寄存器而没有保存栈上的数据,A继续执行可能会覆盖B和post_http_request的栈内容,导致切换失败。或者是否需要保存栈上的数据,这样会不会有内存拷贝的开销。
|
||||
>
|
||||
> 首先,我需要回顾之前关于协程保存栈帧的讨论。之前提到协程必须保存当前栈帧,包括寄存器、局部变量等。用户可能误解了栈帧保存的方式,认为只保存寄存器而没有保存栈上的数据,但实际上,正确的实现需要保存整个栈帧的内容,或者使用独立的栈空间来避免被覆盖。
|
||||
>
|
||||
> 接下来,需要解释协程的不同实现方式。比如,在Python这样的生成器协程中,每个协程可能共享同一个栈,通过生成器对象保存局部变量和指令指针,但可能不保存整个栈帧。而在其他语言如Go或Rust中,每个协程可能有自己的独立栈,切换时切换栈指针,这样就不会被其他协程覆盖。
|
||||
>
|
||||
> 用户提到的例子中,当post_http_request被await挂起时,协程需要保存当前的执行状态,包括B函数和post_http_request的栈数据。如果所有协程共享同一个栈,那么当A继续执行时,确实可能覆盖之前的栈内容,导致数据损坏。因此,正确的实现需要每个协程有自己的栈空间,或者在挂起时将栈数据拷贝到堆中保存。
|
||||
>
|
||||
> 需要解释不同语言的实现策略。例如,Go使用分段栈或连续栈,每个goroutine有自己的栈,切换时只需切换栈指针,无需拷贝数据。而Python的协程可能使用生成器,保存必要的状态而非整个栈,适用于浅层调用,但深层嵌套可能有问题。
|
||||
>
|
||||
> 用户担心内存拷贝的开销,需要说明高性能实现(如Go、Rust)如何通过独立栈来避免拷贝,只在必要时增长栈或分配新栈,从而减少开销。同时,解释栈切换的机制,比如修改栈指针寄存器,使得每个协程使用不同的内存区域,互不干扰。
|
||||
>
|
||||
> 可能还需要提到,当协程挂起时,其栈帧的数据必须保留在内存中,直到恢复执行。如果使用独立栈,这些数据不会被其他协程覆盖,因此无需拷贝。而如果共享栈,则需要将栈数据保存到堆中,这会带来拷贝开销,但现代实现通常避免这种方式,选择为每个协程分配独立栈空间。
|
||||
>
|
||||
> 总结用户的疑问点:是否保存栈数据,如何避免覆盖,以及性能影响。需要明确回答,协程实现中栈帧的保存方式,不同策略的优缺点,以及如何解决用户提到的覆盖问题。
|
||||
|
||||
|
||||
|
||||
协程的实现中,**栈帧保存机制**是保证正确性的核心,但具体实现方式在不同语言/框架中有显著差异。针对你的场景,我们分层次解析:
|
||||
|
||||
---
|
||||
|
||||
### 一、场景还原与问题本质
|
||||
```python
|
||||
async def A():
|
||||
b = await B() # <- 挂起点1
|
||||
|
||||
async def B():
|
||||
await post_http_request() # <- 挂起点2
|
||||
```
|
||||
|
||||
当执行到`post_http_request()`中的`await`时:
|
||||
1. **调用链**:`A → B → post_http_request`
|
||||
2. **挂起顺序**:从`post_http_request`开始逐层向上传递`await`
|
||||
3. **关键问题**:如何保存整个调用链的上下文,避免栈数据被覆盖?
|
||||
|
||||
---
|
||||
|
||||
### 二、两种核心实现策略
|
||||
|
||||
#### 1. **共享栈模式 (Stackful Coroutines)**
|
||||
- **代表实现**:Go goroutine、C++ Boost.Coroutine
|
||||
- **核心机制**:
|
||||
- 每个协程拥有**独立栈空间**(初始大小通常为2KB-8KB)
|
||||
- 切换时**直接切换栈指针寄存器**(如x86的`rsp`)
|
||||
- **无需拷贝栈数据**,通过内存隔离避免覆盖
|
||||
|
||||
```c
|
||||
// Go调度器伪代码
|
||||
func schedule() {
|
||||
// 保存当前协程的rsp
|
||||
old_gobuf.rsp = get_rsp()
|
||||
// 切换到目标协程的栈
|
||||
set_rsp(new_gobuf.rsp)
|
||||
ret
|
||||
}
|
||||
```
|
||||
|
||||
- **内存布局**:
|
||||
```
|
||||
┌─────────────┐
|
||||
│ 协程X的栈 │ ← rsp_x
|
||||
├─────────────┤
|
||||
│ 协程Y的栈 │ ← rsp_y
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
- **优势**:零拷贝,切换极快(约35ns)
|
||||
- **代价**:每个协程需预分配独立栈空间
|
||||
|
||||
#### 2. **状态机模式 (Stackless Coroutines)**
|
||||
- **代表实现**:Python AsyncIO、Rust async/await
|
||||
- **核心机制**:
|
||||
- 通过编译器将协程转换为**状态机**
|
||||
- **所有局部变量提升到堆分配的结构体**
|
||||
- 栈帧数据**显式保存到堆内存**
|
||||
|
||||
```rust
|
||||
// Rust async编译后伪代码
|
||||
struct B_Future {
|
||||
state: u32,
|
||||
local_vars: B_Locals, // 包含所有局部变量
|
||||
}
|
||||
|
||||
struct A_Future {
|
||||
b_future: B_Future,
|
||||
local_b: Option<B_Result>,
|
||||
}
|
||||
```
|
||||
|
||||
- **内存布局**:
|
||||
```
|
||||
┌─────────────┐
|
||||
│ 主线程栈 │ ← 仅存状态机指针
|
||||
├─────────────┤
|
||||
│ 堆内存 │ ← 存储所有协程状态
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
- **优势**:内存占用低(无独立栈)
|
||||
- **代价**:局部变量访问需间接寻址(约多1-3 CPU周期)
|
||||
|
||||
---
|
||||
|
||||
### 三、关键问题解答
|
||||
|
||||
#### Q1:为何不会发生栈数据覆盖?
|
||||
- **共享栈模式**:每个协程有独立栈空间,物理内存不重叠
|
||||
- **状态机模式**:所有协程共享主线程栈,但局部变量已提升到堆,栈上仅存指针
|
||||
|
||||
#### Q2:栈数据是否需要显式拷贝?
|
||||
- **共享栈模式**:无需拷贝,切换时直接跳转栈指针
|
||||
- **状态机模式**:编译器自动将局部变量"移动"到堆结构体,相当于隐式拷贝
|
||||
|
||||
#### Q3:性能影响对比
|
||||
| 指标 | 共享栈模式 | 状态机模式 |
|
||||
|---------------------|----------------|----------------|
|
||||
| 单次切换延迟 | ~35ns | ~18ns |
|
||||
| 内存开销/协程 | 2KB-8KB | 200B-2KB |
|
||||
| 局部变量访问速度 | 直接栈访问 | 间接堆访问 |
|
||||
| 最大协程数 (8GB) | ~1,000,000 | ~10,000,000 |
|
||||
|
||||
---
|
||||
|
||||
### 四、具体语言实现剖析
|
||||
|
||||
#### 1. **Python AsyncIO**
|
||||
- **实现方式**:生成器+状态机
|
||||
- **栈保存机制**:
|
||||
- 每个`async def`函数转换为生成器
|
||||
- 局部变量保存在生成器对象的`gi_frame`中
|
||||
- **不保存完整栈**,依赖事件循环调度
|
||||
|
||||
```python
|
||||
async def B():
|
||||
x = 42 # 存储在生成器对象的x属性
|
||||
await post_http_request()
|
||||
print(x) # 从生成器对象读取x
|
||||
|
||||
# 编译后伪代码
|
||||
class B_Generator:
|
||||
def __init__(self):
|
||||
self.x = None
|
||||
self.state = 0
|
||||
|
||||
def __next__(self):
|
||||
if self.state == 0:
|
||||
self.x = 42
|
||||
self.state = 1
|
||||
return post_http_request()
|
||||
elif self.state == 1:
|
||||
print(self.x)
|
||||
```
|
||||
|
||||
- **缺陷**:无法处理深层递归调用(栈深度受限)
|
||||
|
||||
#### 2. **Rust async/await**
|
||||
- **实现方式**:状态机 + 零成本抽象
|
||||
- **栈保存机制**:
|
||||
- 编译器将每个`async fn`转换为结构体
|
||||
- 所有局部变量成为结构体字段
|
||||
- 通过`Pin`保证内存位置固定
|
||||
|
||||
```rust
|
||||
async fn B() {
|
||||
let x = 42; // 存储在B_Future结构体
|
||||
post_http_request().await;
|
||||
println!("{}", x);
|
||||
}
|
||||
|
||||
// 编译后伪代码
|
||||
struct B_Future {
|
||||
state: u32,
|
||||
x: i32,
|
||||
http_request: PostHttpRequestFuture,
|
||||
}
|
||||
|
||||
impl Future for B_Future {
|
||||
fn poll(...) {
|
||||
match self.state {
|
||||
0 => {
|
||||
self.x = 42;
|
||||
self.state = 1;
|
||||
self.http_request.poll()
|
||||
}
|
||||
1 => {
|
||||
println!("{}", self.x);
|
||||
Poll::Ready(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **优势**:无堆分配(可通过`no_std`运行)
|
||||
|
||||
---
|
||||
|
||||
### 五、实际内存布局示例
|
||||
|
||||
#### 场景:`A → B → post_http_request`挂起
|
||||
- **共享栈模式 (Go)**:
|
||||
```
|
||||
┌─────────────┐
|
||||
│ 协程A栈 │ ← 保存B的返回地址和局部变量
|
||||
├─────────────┤
|
||||
│ 协程B栈 │ ← 保存post_http_request的上下文
|
||||
└─────────────┘
|
||||
```
|
||||
- 每个协程栈独立,切换时硬件自动隔离
|
||||
|
||||
- **状态机模式 (Rust)**:
|
||||
```
|
||||
┌─────────────┐
|
||||
│ 主栈 │ → 仅存储当前活跃Future指针
|
||||
├─────────────┤
|
||||
│ 堆内存 │ ← A_Future { B_Future { PostHttpRequest_Future } }
|
||||
└─────────────┘
|
||||
```
|
||||
- 所有状态嵌套存储在堆上
|
||||
|
||||
---
|
||||
|
||||
### 六、总结
|
||||
- **共享栈协程**通过独立栈空间物理隔离数据,**无需拷贝但预分配内存**
|
||||
- **状态机协程**通过编译期代码变换将栈数据提升到堆,**牺牲局部变量访问速度换取高密度**
|
||||
- 现代语言(如Go/Rust)通过精细设计,使得协程切换开销可控制在**20-50ns**,完全满足高并发需求
|
||||
|
||||
## Round 4
|
||||
|
||||
**Q:**
|
||||
所以你的意思是协程函数会有自己独立的栈空间是吗?这样的话,调用协程函数是否是和调用不同函数时不同,例如需要额外切换 rsp 到对应协程函数所在的栈上?
|
||||
|
||||
**A:**
|
||||
> 好的,我现在需要回答用户关于协程函数是否拥有独立栈空间以及调用协程函数是否需要切换栈指针的问题。用户之前的对话已经涉及协程的实现机制,包括共享栈和状态机模式的区别,以及栈帧保存的必要性。用户现在想确认协程是否使用独立栈空间,以及调用时是否需要切换栈指针。
|
||||
>
|
||||
> 首先,我需要回顾之前的讨论。用户之前的问题集中在协程如何保存栈帧,避免数据被覆盖。我的回答提到了共享栈和状态机两种模式,其中共享栈模式每个协程有独立栈空间,而状态机模式则通过编译器将局部变量提升到堆中。用户现在的问题直接询问协程是否有独立栈空间,以及是否需要切换rsp寄存器。
|
||||
>
|
||||
> 接下来,我需要明确回答用户的问题:是的,某些协程实现(如共享栈模式)确实使用独立栈空间,并在调用时切换rsp。而状态机模式(如Rust的async/await)则不依赖独立栈,而是将状态保存在堆上,因此不需要切换栈指针。
|
||||
>
|
||||
> 需要分点详细解释两种模式的区别,特别是栈的使用和切换机制。同时,要举例说明不同语言中的实现,如Go的goroutine使用共享栈,而Rust使用状态机模式。此外,需要比较两者的优缺点,如性能、内存占用和适用场景。
|
||||
>
|
||||
> 可能还需要涉及技术细节,如栈指针切换的具体操作,如何通过修改rsp寄存器实现协程切换,以及状态机模式如何避免栈切换。同时,要解答用户可能的疑问,比如为什么有的实现选择共享栈,有的选择状态机,各自的权衡是什么。
|
||||
>
|
||||
> 需要确保回答准确,避免错误。例如,Go的协程确实使用分段栈或连续栈,而Rust的async/await在编译时转换为状态机,不依赖独立栈空间。同时,要指出切换栈指针的性能影响,以及状态机模式在内存使用上的优势。
|
||||
>
|
||||
> 最后,总结两种模式的适用场景,帮助用户理解不同协程实现的优缺点,以便根据具体需求选择合适的模型。
|
||||
|
||||
|
||||
|
||||
协程是否拥有独立栈空间取决于具体的实现模型,不同编程语言和框架采用了不同的策略。以下是两种主流实现方式的详细对比:
|
||||
|
||||
---
|
||||
|
||||
### **一、共享栈协程 (Stackful Coroutines)**
|
||||
**代表实现**:Go goroutine、Lua coroutine、C++ Boost.Coroutine
|
||||
**核心特性**:每个协程拥有独立的栈空间,切换时需要修改栈指针寄存器(如x86的`rsp`)。
|
||||
|
||||
#### **1. 栈空间管理**
|
||||
- **独立栈分配**:
|
||||
```go
|
||||
// Go运行时为每个goroutine预分配栈(初始2KB)
|
||||
go func() { // 创建新协程
|
||||
myFunc() // 在独立栈中运行
|
||||
}()
|
||||
```
|
||||
- **栈增长机制**:
|
||||
- **分段栈**(旧版Go):栈不足时分配新段,通过`morestack`检测
|
||||
- **连续栈**(现代Go):栈不足时整体复制到更大的内存区域
|
||||
|
||||
#### **2. 栈指针切换**
|
||||
- **切换原理**:
|
||||
```asm
|
||||
; x86-64协程切换伪代码
|
||||
swapcontext:
|
||||
push rbp
|
||||
mov rbp, rsp
|
||||
; 保存当前寄存器到旧协程上下文
|
||||
mov [old_ctx + RSP_OFFSET], rsp
|
||||
; 加载新协程的栈指针
|
||||
mov rsp, [new_ctx + RSP_OFFSET]
|
||||
; 恢复新协程的寄存器
|
||||
pop rbp
|
||||
ret
|
||||
```
|
||||
- **性能指标**:
|
||||
- 切换耗时:约35-50ns(包括保存/恢复16个通用寄存器+浮点寄存器)
|
||||
- 内存开销:每个协程至少2KB(Go) 到 8KB (C++)
|
||||
|
||||
#### **3. 调用链示例**
|
||||
```go
|
||||
func A() {
|
||||
B() // 切换到B的栈
|
||||
}
|
||||
|
||||
func B() {
|
||||
C() // 切换到C的栈
|
||||
}
|
||||
```
|
||||
**内存布局**:
|
||||
```
|
||||
┌─────────────┐
|
||||
│ 主线程栈 │ ← 0x7ffeefbff000
|
||||
├─────────────┤
|
||||
│ Goroutine A栈 │ ← 0xc000032000
|
||||
├─────────────┤
|
||||
│ Goroutine B栈 │ ← 0xc0000ac000
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### **二、无栈协程 (Stackless Coroutines)**
|
||||
**代表实现**:Rust async/await、Python AsyncIO、JavaScript async
|
||||
**核心特性**:协程共享主线程栈,通过状态机管理上下文,无需切换栈指针。
|
||||
|
||||
#### **1. 实现原理**
|
||||
- **状态机转换**:
|
||||
```rust
|
||||
async fn example() {
|
||||
let a = 1; // 状态0
|
||||
some_io().await; // 状态1
|
||||
println!("{}", a);
|
||||
}
|
||||
```
|
||||
编译后展开为:
|
||||
```rust
|
||||
struct ExampleFuture {
|
||||
state: u32,
|
||||
a: i32,
|
||||
io_future: SomeIOFuture,
|
||||
}
|
||||
```
|
||||
- **栈共享机制**:
|
||||
- 所有局部变量存储在堆上的Future对象中
|
||||
- 主线程栈仅保留当前活跃Future的指针
|
||||
|
||||
#### **2. 内存布局**
|
||||
```
|
||||
┌─────────────┐
|
||||
│ 主线程栈 │
|
||||
│-------------│ ← 仅存储临时变量和返回地址
|
||||
│ Future对象指针 →
|
||||
├─────────────┤
|
||||
│ 堆内存 │
|
||||
│-------------│ ← Future对象包含所有协程状态
|
||||
│ a=1 │
|
||||
│ io_future │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
#### **3. 性能对比**
|
||||
| 指标 | 共享栈协程 | 无栈协程 |
|
||||
|---------------------|----------------|----------------|
|
||||
| 单次切换开销 | ~35ns | ~18ns |
|
||||
| 内存开销/协程 | 2KB-8KB | 200B-2KB |
|
||||
| 最大协程数 (8GB) | ~1,000,000 | ~10,000,000 |
|
||||
| 局部变量访问 | 直接栈访问 | 间接堆访问 |
|
||||
|
||||
---
|
||||
|
||||
### **三、关键问题解答**
|
||||
#### **Q1:协程调用是否需要切换栈指针?**
|
||||
- **共享栈协程**:**需要**显式切换`rsp`寄存器
|
||||
```asm
|
||||
; Go调度器切换goroutine时的关键操作
|
||||
MOVQ gobuf_sp(BX), SP // 加载新协程的栈指针
|
||||
```
|
||||
- **无栈协程**:**无需切换**,所有状态在堆上管理
|
||||
|
||||
#### **Q2:如何避免栈数据覆盖?**
|
||||
- **共享栈**:物理内存隔离,每个协程栈独立分配
|
||||
```c
|
||||
// Go连续栈扩容时的复制操作
|
||||
old_stack = current_stack;
|
||||
new_stack = malloc(new_size);
|
||||
memcpy(new_stack, old_stack, old_size);
|
||||
free(old_stack);
|
||||
```
|
||||
- **无栈协程**:局部变量存储在堆对象中,与调用链解耦
|
||||
|
||||
#### **Q3:为何无栈协程性能更高?**
|
||||
- **寄存器压力小**:无需保存/恢复完整寄存器上下文
|
||||
- **缓存友好**:状态机结构通常更紧凑,提高缓存命中率
|
||||
- **零内存拷贝**:状态切换通过指针跳转完成
|
||||
|
||||
---
|
||||
|
||||
### **四、语言实现对比**
|
||||
#### **1. Go (共享栈)**
|
||||
- **栈增长策略**:
|
||||
- 初始2KB,每次翻倍直至32KB,之后按25%增长
|
||||
- 栈收缩机制防止内存泄漏
|
||||
- **调度优势**:
|
||||
```go
|
||||
// Go调度器在系统线程间均衡goroutine
|
||||
runtime.GOMAXPROCS(4) // 使用4个OS线程
|
||||
```
|
||||
|
||||
#### **2. Rust (无栈)**
|
||||
- **零成本抽象**:
|
||||
```rust
|
||||
// async函数编译为状态机,无额外运行时开销
|
||||
let fut = async { 1 };
|
||||
// 等效于:
|
||||
struct MyFuture { /* 状态字段 */ }
|
||||
impl Future for MyFuture {
|
||||
type Output = i32;
|
||||
fn poll(...) { ... }
|
||||
}
|
||||
```
|
||||
- **无堆分配优化**:
|
||||
```rust
|
||||
// 通过`#[async_trait]`实现动态分发
|
||||
#[async_trait]
|
||||
trait MyTrait {
|
||||
async fn method(&self);
|
||||
}
|
||||
```
|
||||
|
||||
#### **3. Python (生成器式无栈)**
|
||||
- **生成器协程**:
|
||||
```python
|
||||
async def coro():
|
||||
await asyncio.sleep(1)
|
||||
# 转换为:
|
||||
class Coro:
|
||||
def __init__(self):
|
||||
self.state = 0
|
||||
def __next__(self):
|
||||
if self.state == 0:
|
||||
self.state = 1
|
||||
return sleep(1)
|
||||
```
|
||||
- **缺陷**:无法深度递归(受解释器栈限制)
|
||||
|
||||
---
|
||||
|
||||
### **五、总结**
|
||||
- **共享栈协程**:通过独立栈空间和`rsp`切换实现真并发,适合需要深度递归或复杂调用链的场景,以Go为代表。
|
||||
- **无栈协程**:通过状态机和堆存储实现高密度协程,以Rust/Python为代表,适合I/O密集型和高并发场景。
|
||||
- **选择依据**:
|
||||
- 需要**高性能和可控内存** → Rust无栈协程
|
||||
- 需要**简单并发模型和深度调用** → Go共享栈协程
|
||||
- **快速开发和高层抽象** → Python无栈协程
|
||||
|
||||
两种模型各有优劣,现代运行时(如.NET)甚至支持混合模式,开发者应根据具体需求选择最合适的协程实现。
|
||||
@ -1,73 +0,0 @@
|
||||
---
|
||||
title: Swift 学习笔记 - 配置 VS Code 开发 Flutter iOS Native 代码
|
||||
date: 2025-05-15 09:53:53
|
||||
tags:
|
||||
- Swift
|
||||
- iOS
|
||||
- Flutter
|
||||
- 编程语言
|
||||
categories:
|
||||
- 技术
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
这段时间学 Swift 就冲着一个 iOS 开发来的,这学完了必须给环境整上,虽然说 VS Code 和 XCode 搭配着用也没什么不好的,毕竟开发过程中再怎么也没法避免打开 XCode 的过程 (比如配置开发者签名之类的),但是能够写码时全程不离开当前 IDE 终归还是让人感觉会舒服很多,遂研究如何在 VS Code 中配置 Swift 开发环境以及正常开发 Flutter/ios 项目
|
||||
|
||||
## 配置 Swift 插件
|
||||
|
||||
根据官方提供的文档 [Configuring VS Code for Swift Development](https://www.swift.org/documentation/articles/getting-started-with-vscode-swift.html),可以在 VS Code 插件商店中下载 Swift 插件,即可开始编写一般的 Swift 项目了
|
||||
|
||||
{% note warning flat %}
|
||||
在 Windows 上,Swift 插件可能会因为编码原因拒绝工作,例如出现错误 `Unable to parse output from 'swift package init --help'`,目前在本地未能解决,建议直接使用 ssh 远程 Mac 机器进行开发
|
||||
{% endnote %}
|
||||
|
||||
安装完插件后,对于一般的普通 Swift 项目,插件看起来能够正常工作,包括 F5 运行等都可以正常使用。似乎编译器的依赖关系是通过 `Package.swfit` 解析的,正确配置 `Package.swift` 即可在一般项目里正常开发
|
||||
|
||||
## No such module 'Flutter'
|
||||
|
||||
创建完 Flutter 工程后,进入 `ios` 文件夹下 Flutter 创建的 swift 文件,会发现报错 `no such module 'Flutter'`,此时如果使用 XCode 打开同样会有此报错
|
||||
|
||||
由于是初始工程,没有添加任何使用了 native code 的插件,因此在 `ios` 文件夹下是**不会有 Podfile 文件的**,网上关于这个问题的解决方法大多是 `pod install`,对当前的问题没有任何效果,可以不用尝试了
|
||||
|
||||
误打误撞发现在 XCode 下选择 Product-Analyze 后,XCode 内报错消失,猜测是 XCode 成功找到了 Flutter 相关的库文件,然而 Swift 插件由于缺少 `Package.swift` 文件,依然无法找到 Flutter 模块
|
||||
|
||||
遂就此思路询问 Gemini,借助大模型强大的 DeepResearch 能力,得到了下述解决方案
|
||||
|
||||
### 安装 SweetPad
|
||||
|
||||
首先,在 VSCode 插件商店中搜索 SweetPad 插件并安装,安装完成后左侧工具栏会出现糖果图标
|
||||
|
||||
点击糖果图标进入 SweetPad 配置页面,大概率 SweetPad 已经自动识别了工程。如果没有,可能需要在 `.vscode/settings.json` 中手动添加配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"sweetpad.build.xcodeWorkspacePath": "ios/Runner.xcworkspace"
|
||||
}
|
||||
```
|
||||
|
||||
之后重新加载 VS Code
|
||||
|
||||
### 安装 xcode-build-server
|
||||
|
||||
使用 brew 安装 xcode-build-server:
|
||||
|
||||
```bash
|
||||
brew install xcode-build-server
|
||||
```
|
||||
|
||||
如果想的话,可以额外安装 xcbeautify (SweetPad 推荐):
|
||||
|
||||
```bash
|
||||
brew install xcbeautify
|
||||
```
|
||||
|
||||
### 生成 buildServer.json
|
||||
|
||||
打开命令面板 (或使用 `Ctrl` + `Shift` + `P` / `Command` + `Shift` + `P`),搜索 `SweetPad`
|
||||
|
||||
选中 **SweetPad: Generate Build Server Config**
|
||||
|
||||
SweetPad 应该会提示选择对应的 scheme,例如 `Runner` 以及目标设备等
|
||||
|
||||
完成后,SweetPad 应该会生成例如 `buildServer.json` 或是 `compile_commands.json` 这样的文件,之后重新启动 VS Code 即可
|
||||
@ -1,606 +0,0 @@
|
||||
---
|
||||
title: Swift 学习笔记 - 从 Property Wrapper 视角探索 State 与 Binding 如何工作
|
||||
date: 2025-05-12 23:30:52
|
||||
tags:
|
||||
- Swift
|
||||
- iOS
|
||||
- 编程语言
|
||||
categories:
|
||||
- 技术
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
基本学完 Swift 语法后,就开始跟随 [Apple 关于 Swift UI 的官方教程](https://developer.apple.com/tutorials/swiftui-concepts) 来学习 Swift 如何应用在 iOS App 开发中。
|
||||
|
||||
在 [Driving Changes in your UI with State and Bindings](https://developer.apple.com/tutorials/swiftui-concepts/driving-changes-in-your-ui-with-state-and-bindings) 这一小节中,教程首次引入了 Swift UI 中关于状态的概念。
|
||||
|
||||
教程中使用了简单的代码来介绍 `@State` 和 `@Binding` 属性,以及如何在工程中使用这两个属性:
|
||||
|
||||
```swift
|
||||
import Foundation
|
||||
|
||||
// 省略部分代码 ...
|
||||
struct RecipeEditorConfig {
|
||||
var recipe = Recipe.emptyRecipe()
|
||||
var shouldSaveChanges = false
|
||||
var isPresented = false
|
||||
// 省略部分代码 ...
|
||||
}
|
||||
```
|
||||
|
||||
```swift
|
||||
import SwiftUI
|
||||
|
||||
|
||||
struct ContentListView: View {
|
||||
// 省略部分代码 ...
|
||||
@State private var recipeEditorConfig = RecipeEditorConfig()
|
||||
|
||||
var body: some View {
|
||||
// 省略部分代码 ...
|
||||
RecipeEditor(config: $recipeEditorConfig)
|
||||
// 省略部分代码 ...
|
||||
}
|
||||
|
||||
// 省略部分代码 ...
|
||||
}
|
||||
```
|
||||
|
||||
```swift
|
||||
import SwiftUI
|
||||
|
||||
struct RecipeEditor: View {
|
||||
@Binding var config: RecipeEditorConfig
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
RecipeEditorForm(config: $config)
|
||||
// 省略部分代码 ...
|
||||
}
|
||||
}
|
||||
// 省略部分代码 ...
|
||||
}
|
||||
```
|
||||
|
||||
由于之前也接触过其他的前端语言,例如 Dart 或是 JavaScript,自然地就会好奇:Swift 这样的语法设计下,是怎么实现状态的刷新的呢?
|
||||
|
||||
## 一些猜测
|
||||
|
||||
比较直接的猜测就是,传入的 `$config` 类似闭包语法,实则是传递了一个 `{ config in return config }` 闭包给 `RecipeEditor`
|
||||
|
||||
这看起来好像解答了为什么 `RecipeEditor` 可以直接修改 `ContentListView` 中 `recipeEditorConfig` 的值,但实则有几个关键的问题没有解答:
|
||||
|
||||
1. `@State` 存在的意义是在状态变更的时候通知渲染层进行重绘,类似 `setState` 方法,如果直接传入变量闭包,虽然可以修改,但是如何通知 UI 层重绘?
|
||||
2. 如果传入的是闭包,闭包是如何赋值给 `RecipeEditorConfig` 这样的变量的?为什么没有 `init` 函数依然可以完成这样的赋值?
|
||||
3. 在 `RecipeEditor` 中,是可以直接修改 `config` 的值的,例如直接给 `config` 赋新值,如果原本的引用通过闭包传入后解析出来赋值给 `config`,那这样修改后不就丢失了对原先的对象的引用吗?
|
||||
|
||||
几番提问下来,基本可以肯定应该不是直接通过闭包传递的,而是 `@State` 和 `@Binding` 做了更复杂的处理
|
||||
|
||||
而具体做了哪些处理,则要从 Property Wrapper 开始讲起
|
||||
|
||||
## Swift 中的 Property Wrapper
|
||||
|
||||
在一篇关于 `@State` 和 `@Binding` 的问答中,我了解到,这两个属性并非宏 (至少不是库中按照 Macro 描述编写的那种宏),而是受语言支持的两个 Property Wrapper
|
||||
|
||||
关于 Property Wrapper 的内容,在 [Swift Evolution - Property Wrapper](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0258-property-wrappers.md) 中有提到整个 Property Wrapper 设计的背景、提案和应用场景
|
||||
|
||||
简单说来,Property Wrapper 可以分为 3 步:
|
||||
|
||||
1. 编写由 `@propertyWrapper` 标识的结构体,这个结构体用于存储包装属性所需要的所有额外信息以及可以提供的方法(例如一个 lazy 包装属性可能会想要存储当前变量是否已经被初始化过、初始化后的值等),这个结构体会暴露 `wrappedValue` 和 `projectValue` 两个属性供外部访问
|
||||
2. (猜想): 编译器识别到 `@propertyWrapper`,将后续结构体对应的 AST 转化为包含该结构体定义的,更复杂的宏定义
|
||||
3. 编译器识别到 `@MyPropertyWrapper`,将后续对应的声明 AST 转化为更复杂的表达式,包括一个私有 `MyPropertyWrapper<Value>` 结构体对象和对应的访问器
|
||||
|
||||
### PropertyWrapper 结构体
|
||||
|
||||
Property Wrapper 这一方案的提出意图解决的问题就是,当访问一个变量时,能够在不增加越来越多且复杂的编译器属性的同时,支持将通用的、可复用的额外逻辑作用在这个访问过程中
|
||||
|
||||
例如,关键词 `lazy` 本质上就是在试图解决这类问题中的一个: 当访问一个变量时,先判断是否初始化,如果初始化则返回初始化后的值,否则进行初始化
|
||||
|
||||
最初的想法可能就是,写一些重复的代码 (boilerplate code) 来实现这个功能:
|
||||
|
||||
```swift
|
||||
// Irrelevant code omitted
|
||||
struct MyClass {
|
||||
var _myDefaultValue: Int = 123
|
||||
var _myValue: Int? = nil
|
||||
var myValue: Int {
|
||||
get {
|
||||
if _myValue == nil {
|
||||
_myValue = _myDefaultValue
|
||||
}
|
||||
guard let v = _myvalue else {
|
||||
raise fatalError()
|
||||
}
|
||||
return v
|
||||
}
|
||||
set {
|
||||
_myValue = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
但是如果每一个需要用到这个逻辑的地方都要这么些,未免有些复杂
|
||||
|
||||
考虑到宏可以扩展代码,也许可以定义一个宏 `@Lazy` 来展开这些内容,从而避免每次都重复编写
|
||||
|
||||
```swift
|
||||
// Irrelevant code omitted
|
||||
struct MyClass {
|
||||
// 也就是,将
|
||||
@Lazy var myValue: Int = 123
|
||||
// 转换为
|
||||
var _myValue: Int? = nil
|
||||
var myValue: Int {
|
||||
get {
|
||||
if _myValue == nil {
|
||||
_myValue = 123
|
||||
}
|
||||
guard let v = _myvalue else {
|
||||
raise fatalError()
|
||||
}
|
||||
return v
|
||||
}
|
||||
set {
|
||||
_myValue = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
然而,具体的宏想想就已经非常复杂了
|
||||
|
||||
也就是说,如果想要直接用宏来实现给属性访问添加额外逻辑这一能力,虽然可以做到,但那样的话似乎更多的关注会在如何实现宏本身而不是实际的逻辑上了 (Make 的困境幻视)
|
||||
|
||||
那么,从语言设计的角度考虑,是不是可以让开发者更关注于具体逻辑的实现而隐藏宏转换的细节呢?类似 CMake 那样,用一套规范来约束开发者对逻辑的定义,再在这一规范之上构建编译器属性,使得能够将具体逻辑转换成某种编译器能识别的宏,从而开发者只需要遵循规范来定义逻辑,而不再需要编写具体的宏定义了
|
||||
|
||||
虽然不知道 Swift 具体是怎么实现的,但我想 Property Wrapper 的出现大概率是相似的思路
|
||||
|
||||
在 Property Wrapper 的描述中,Swift 约定了一个编译器属性 `@propertyWrapper`,其可以用于修饰自定义的结构体,结构体的一般结构约定如下:
|
||||
|
||||
```swift
|
||||
@propertyWrapper
|
||||
struct MyPropertyWrapper {
|
||||
private var myPropertyUnderlyValue: MyValueType
|
||||
|
||||
init(myPropertyUnderlyValue: MyValueType) {
|
||||
self.myPropertyUnderlyValue = myPropertyUnderlyValue
|
||||
}
|
||||
|
||||
var wrappedValue: MyValueType {
|
||||
get {
|
||||
// Add logic when get
|
||||
myPropertyUnderlyValue
|
||||
}
|
||||
set {
|
||||
// Add logic when set
|
||||
myPropertyUnderlyValue = newValue
|
||||
}
|
||||
}
|
||||
|
||||
var projectedValue: SomeProjectedType {
|
||||
get {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
我想,之所以这么设计,是因为通过 `struct` 可以提供一个统一的抽象语法树节点来描述整个额外逻辑所需要的所有内容。想象如果不使用 `struct` 结构来包装,那为了实现 `@Lazy` 的能力,需要找一个地方写 `_myValue` 变量,另找一个地方写 `myValue` 需要的 `getter` 和 `setter` 逻辑,并且编译器要能够直到这些分散的表达式节点都是 `@Lazy` 的一部分,这想想就不好实现
|
||||
|
||||
通过 `struct` 的包装,虽然使得实际值的存储多了一层结构体的封装,但简化了编译器的实现,并且也统一了可能的表现形式
|
||||
|
||||
现在,编译器只需要在将这个结构体转换为一个宏,其在标记了对应属性的声明处进行如下操作即可:
|
||||
|
||||
1. 将原先的声明替换为一个 `_originalVarName: MyPropertyWrapper` 的值
|
||||
2. 声明 `var originalVarName: MyPropertyWrapper { get, set }` 访问器,通过 `MyPropertyWrapper.wrappedValue` 进行访问
|
||||
3. 声明 `var $originalVarName: SomeProjectedType { get, set }` 访问器,通过 `MyPropertyWrapper.projectedValue` 进行访问
|
||||
4. 生成 synthesized initializer (如果必须)
|
||||
|
||||
看起来就很好实现了,不是吗?
|
||||
|
||||
最后生成出来的代码大致可以理解成下面这样:
|
||||
|
||||
```swift
|
||||
// Original class declaration
|
||||
// struct MyClass {
|
||||
// @MyPropertyWrapper var myPropertyValue: MyValueType
|
||||
// }
|
||||
|
||||
// Converted class declaration
|
||||
struct MyClass {
|
||||
private var _myPropertyValue: MyPropertyWrapper
|
||||
|
||||
var myPropertyValue: MyValueType {
|
||||
get {
|
||||
_myPropertyValue.wrappedValue
|
||||
}
|
||||
set {
|
||||
_myPropertyValue.wrappedValue = newValue
|
||||
}
|
||||
}
|
||||
|
||||
var $myPropertyValue: SomeProjectedType {
|
||||
get {
|
||||
_myPropertyValue.projectedValue
|
||||
}
|
||||
set {
|
||||
_myPropertyValue.projectedValue = newValue
|
||||
}
|
||||
}
|
||||
|
||||
init(myPropertyValue: MyValueType) {
|
||||
_myPropertyValue = MyPropertyWrapper(myPropertyUnderlyValue: myPropertyValue)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### projectedValue 和 wrappedValue
|
||||
|
||||
在 `MyPropertyWrapper` 的实现中,`wrappedValue` 的作用比较显而易见: 提供了一个对实际存储内容的访问器,在访问器中实现了需要对目标属性添加的额外访问逻辑。而通过将 `myPropertyValue` 改为对 `_myPropertyValue.wrappedValue` 的访问器,可以满足在当前类内对目标属性的访问经过所需要的额外逻辑
|
||||
|
||||
然而,只暴露 `wrappedValue` 往往不能满足需求,因为在类内访问 `self.myPropertyValue` 会直接经过 `_myPropertyValue.wrappedValue.get` 解析到 `_myPropertyValue.myPropertyUnderlyValue` 这一实际的底层存储值,这对变量传递就不太友好了: 如果我试图将这个属性传递到其他的函数中,它就会作为实际存储值进行传递,而不会再传递对应的访问器了!
|
||||
|
||||
虽然 `@autoclosure` 看似可以解决这个问题,但是这需要被调函数进行主动适配,显然不能够满足所有的情况。我猜想,Swift 语言的开发团队就是为此类情况而额外支持了一种访问器: `projectedValue`
|
||||
|
||||
`projectedValue` 可以由开发者自由指定需要返回的对象,并且在类内以 `$myPropertyValue` 的形式暴露,一个简单的设计就是:
|
||||
|
||||
```swift
|
||||
var projectedValue: MyPropertyWrapper {
|
||||
self
|
||||
}
|
||||
```
|
||||
|
||||
提供一个对 `MyPropertyWrapper` 实例对象的 `get` 访问器,从而当使用 `$myPropertyValue` 进行传参时,依然可以通过传入参数的 `wrappedValue` 来访问并且修改底层存储值
|
||||
|
||||
## State 的魔法
|
||||
|
||||
在 SwiftUI 中,`@State` 就是依赖了 Property Wrapper 这一语言特性
|
||||
|
||||
很显然,对于显示内容状态的存储,完全符合了 Property Wrapper 的适用场景:
|
||||
|
||||
- View 的数据需要以成员变量形式存储并且加以访问和修改 (对象为类的成员变量)
|
||||
- 对 View 所引用数据的修改需要通知 UI 框架进行重绘 (变量需要在被赋值时添加额外的通知逻辑)
|
||||
- 数据需要能够沿着控件树向下传递,并且子树也能够对存储数据进行修改 (包含额外逻辑的变量访问器需要能够作为参数传递)
|
||||
- 对于所有的 View 需要的数据,虽然类型不同,但依赖的逻辑相同 (需要多处复用的额外逻辑)
|
||||
|
||||
### 可能的 State 结构体
|
||||
|
||||
要实现对状态的存储和访问,`@State` 包装主要需要做的就是在变量被赋值时,触发 UI 更新逻辑
|
||||
|
||||
因此,一个可能的简化版的 `@State` 大概会是如下的样子:
|
||||
|
||||
```swift
|
||||
@propertyWrapper
|
||||
struct State<T> {
|
||||
private state: T
|
||||
|
||||
var wrappedValue: T {
|
||||
get {
|
||||
state
|
||||
}
|
||||
set {
|
||||
state = newValue
|
||||
// call UI update logic
|
||||
}
|
||||
}
|
||||
|
||||
var projectedValue: State<T> {
|
||||
get {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
init(state: T) {
|
||||
self.state = state
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 展开后的 @State 代码
|
||||
|
||||
参考 Property Wrapper 展开的逻辑,对于使用了 `@State` 的成员变量,其简化后的展开代码大概会是下面的样子:
|
||||
|
||||
```swift
|
||||
class MyView: View {
|
||||
// Original declaration
|
||||
// @State var counter: Int = 5
|
||||
|
||||
// Expanded declaration
|
||||
var _counter: State<Int> = State(5)
|
||||
|
||||
var counter: Int {
|
||||
get {
|
||||
_counter.wrappedValue
|
||||
}
|
||||
set {
|
||||
_counter.wrappedValue = newValue
|
||||
}
|
||||
}
|
||||
|
||||
var $counter: State<Int> {
|
||||
get {
|
||||
_counter.projectedValue
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
// View hierarchy
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
对于当前的 View 来说,直接使用例如 `counter = counter + 1` 可以更新状态值并且触发 UI 更新逻辑;而通过传递 `$counter` 则可以允许其他 View 通过 `State<Int>` 提供的接口来更新状态值并且触发 UI 更新逻辑
|
||||
|
||||
### 使用 Binding 作为 projectedValue
|
||||
|
||||
这看起来已经解决了大半的问题,但还有一些情况需要考虑: 例如有时可能会需要将已有的属性外再添加一些逻辑后再传递给子控件
|
||||
|
||||
对于这种情况,如果直接传递 `$state` 所对应的 `State<Int>` 对象,显然是不满足要求的,而如果只是传递一个 `@autoclosure`,又没办法实现 `set` 能力
|
||||
|
||||
因此,可以想到的就是创建一个额外的类,来包含需要新增的逻辑,并提供访问接口 (就像另一个 Property Wrapper 那样):
|
||||
|
||||
```swift
|
||||
class WierdCounter {
|
||||
let get: () -> Int
|
||||
let set: (Int) -> Void
|
||||
|
||||
init(get: () -> Int, set: (Int) -> Void) {
|
||||
self.get = get
|
||||
self.set = set
|
||||
}
|
||||
|
||||
var wrappedValue: Int {
|
||||
get {
|
||||
self.get()
|
||||
}
|
||||
nonmutating set {
|
||||
self.set(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MyView: View {
|
||||
@State var counter: Int = 5
|
||||
|
||||
var wrappedCounter: WierdCounter = WierdCounter {
|
||||
counter
|
||||
}, set: { newValue in
|
||||
counter = newValue * 2
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
MyAnotherView(counter: wrappedCounter)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
此时,更进一步地思考,目前子控件想要访问父控件传入的状态的话,多少还是有些复杂的: 不仅有时候传入 `State<T>` 有时候传入额外的类,访问实际状态的时候还要经过额外的一层访问器去访问
|
||||
|
||||
相比 Swift 设计团队也是这么想的,于是他们首先解决了第一个问题: 先让传入的状态参数类型统一
|
||||
|
||||
这其实还比较好实现,不难发现,`WierdCounter` 和 `State<T>` 其实有着相似的结构,而且他们的核心目标都是需要访问 `counter.wrappedValue`,因此不妨在库中就提供一个类似的类,例如 `MyBinding<T>`:
|
||||
|
||||
```swift
|
||||
struct MyBinding<T> {
|
||||
let get: () -> T
|
||||
let set: (T) -> Void
|
||||
|
||||
var wrappedValue: T {
|
||||
get {
|
||||
self.get()
|
||||
}
|
||||
nonmutating set {
|
||||
self.set(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
init(@escaping get: () -> T, @escaping set: (T) -> Void) {
|
||||
self.get = get
|
||||
self.set = set
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
对应的,`State<T>` 的设计也进行相应的调整:
|
||||
|
||||
```swift
|
||||
@propertyWrapper
|
||||
struct State<T> {
|
||||
private state: T
|
||||
|
||||
var wrappedValue: T {
|
||||
get {
|
||||
state
|
||||
}
|
||||
set {
|
||||
state = newValue
|
||||
// call UI update logic
|
||||
}
|
||||
}
|
||||
|
||||
var projectedValue: MyBinding<T> {
|
||||
MyBinding {
|
||||
self.wrappedValue
|
||||
}, set { newValue in
|
||||
self.wrappedValue = newValue
|
||||
}
|
||||
}
|
||||
|
||||
init(state: T) {
|
||||
self.state = state
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这样,展开后的 `MyView` 类也会有相应的变化:
|
||||
|
||||
```swift
|
||||
class MyView: View {
|
||||
// Original declaration
|
||||
// @State var counter: Int = 5
|
||||
|
||||
// Expanded declaration
|
||||
var _counter: State<Int> = State(5)
|
||||
|
||||
var counter: Int {
|
||||
get {
|
||||
_counter.wrappedValue
|
||||
}
|
||||
set {
|
||||
_counter.wrappedValue = newValue
|
||||
}
|
||||
}
|
||||
|
||||
var $counter: MyBinding<Int> {
|
||||
_counter.projectedValue
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
// View hierarchy
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
统一了状态传参的类型,接下来,对于第二个问题,就是 `@Binding` 展现魔法的时刻了
|
||||
|
||||
## Binding 的魔法
|
||||
|
||||
其实读到这里,难免会有一种不由自主的冲动:
|
||||
|
||||
既然状态参数的类型都统一了,那要简化子控件访问状态的逻辑,不就是要打包 `incomingState.wrappedValue` 这个逻辑嘛!让每个对 `incomingState` 的访问都以 `incomingState.wrappedValue` 的方式进行,不就可以了嘛?
|
||||
|
||||
没错!还记得 Property Wrapper 就是干这事的吧!
|
||||
|
||||
想必 Swift 开发团队也是这么想的,于是就有了 `@Binding`
|
||||
|
||||
### 可能的 Binding 结构体
|
||||
|
||||
有了 `@State` 的经验,猜想 `@Binding` 的实现就简单多了
|
||||
|
||||
不妨猜测简化后的 `@Binding` 如下:
|
||||
|
||||
```swift
|
||||
@propertyWrapper
|
||||
struct Binding<T> {
|
||||
let _binding: MyBinding<T>
|
||||
|
||||
let wrappedValue: T {
|
||||
get {
|
||||
self._binding.wrappedValue
|
||||
}
|
||||
nonmutating set {
|
||||
self._binding.wrappedValue = newValue
|
||||
}
|
||||
}
|
||||
|
||||
let projectedValue: Binding<T> {
|
||||
self
|
||||
}
|
||||
|
||||
init(@escaping get: () -> T, @escaping set: (T) -> Void) {
|
||||
self.get = get
|
||||
self.set = set
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这看起来和 `MyBinding<T>` 的定义也太像了!不妨试试合二为一:
|
||||
|
||||
```swift
|
||||
@propertyWrapper
|
||||
struct Binding<T> {
|
||||
let get: () -> T
|
||||
let set: (T) -> Void
|
||||
|
||||
let wrappedValue: Binding<T> {
|
||||
get {
|
||||
self.get()
|
||||
}
|
||||
nonmutating set {
|
||||
self.set(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
let projectedValue: Binding<T> {
|
||||
self
|
||||
}
|
||||
|
||||
init(@escaping get: () -> T, @escaping set: (T) -> Void) {
|
||||
self.get = get
|
||||
self.set = set
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
太神奇了,这样甚至不知不觉中统一了 `Binding<T>` 和 `MyBinding<T>` 的实现!接下来,只要让编译器生成 `synthesized initializer` 的时候适配 `Binding<T>` 的初始化方式 (使用 `get` 和 `set` 参数而不是 `Binding<T>` 对象) 似乎就完成了
|
||||
|
||||
### 展开后的 @Binding 代码
|
||||
|
||||
那么,根据 Property Wrapper 的展开逻辑,我们来推测一下 `@Binding` 展开后的代码:
|
||||
|
||||
```swift
|
||||
struct MySubView: View {
|
||||
// Original declaration
|
||||
// @Binding var parentState: Int
|
||||
|
||||
// Expanded declaration
|
||||
var _parentState: Binding<Int>
|
||||
|
||||
var parentState: Int {
|
||||
get {
|
||||
_parentState.wrappedValue
|
||||
}
|
||||
set {
|
||||
_parentState.wrappedValue = newValue
|
||||
}
|
||||
}
|
||||
|
||||
var $parentState: Binding<Int> {
|
||||
get {
|
||||
_parentState.projectedValue
|
||||
}
|
||||
}
|
||||
|
||||
// Synthesized initializer
|
||||
init(parentState: Binding<Int>) {
|
||||
self._parentState = Binding {
|
||||
parentState.wrappedValue
|
||||
}, set: { newValue in
|
||||
parentState.wrappedValue = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 一些奇怪的问题记录
|
||||
|
||||
至此,基本完成了对 Swift 中 `@State` 和 `@Binding` 实现原理的深入探索了,但难免还会有一些未解的疑惑
|
||||
|
||||
感谢如今大模型的能力,使得部分奇怪的问题得以解答
|
||||
|
||||
以下列出部分与大模型问答的摘要
|
||||
|
||||
### 手动指定 initializer
|
||||
|
||||
#### 提问
|
||||
|
||||
如果 `@State` 和 `@Binding` 的生成涉及 `synthesized initializer` 的生成,那么如果我自己手动指定了 initializer,是否与前述中自动生成的 initializer 产生冲突?
|
||||
|
||||
#### 回答
|
||||
|
||||
会产生影响,如果手动指定了 initializer,需要手动添加对应的初始化逻辑,例如:
|
||||
|
||||
```swift
|
||||
init(parentState: Binding<Int>, someOtherParams: MyType) {
|
||||
// Add the initialization of bindings
|
||||
self._parentState = Binding {
|
||||
parentState.wrappedValue
|
||||
}, set: { newValue in
|
||||
parentState.wrappedValue = newValue
|
||||
}
|
||||
|
||||
// Other logic
|
||||
}
|
||||
```
|
||||
@ -284,7 +284,7 @@ aside:
|
||||
enable: true
|
||||
icon: fab fa-github
|
||||
text: Follow Me
|
||||
link: https://github.com/Linloir
|
||||
link: https://github.com/xxxxxx
|
||||
card_announcement:
|
||||
enable: true
|
||||
content: This is my Blog
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user