Compare commits

..

12 Commits

Author SHA1 Message Date
84fc93bcc4 feat: update sweetpad configuration
All checks were successful
Deploy Pages / Generate-Pages (push) Successful in 36s
2025-05-16 13:32:59 +08:00
2266091d11 fix: github link
All checks were successful
Deploy Pages / Generate-Pages (push) Successful in 1m18s
2025-05-13 22:43:15 +08:00
1055927991 feat: remove post
All checks were successful
Deploy Pages / Generate-Pages (push) Successful in 1m33s
2025-05-13 22:15:36 +08:00
b6d8b90872 feat: update
All checks were successful
Deploy Pages / Generate-Pages (push) Successful in 1m33s
2025-05-13 21:26:35 +08:00
514d06ceba feat: update
All checks were successful
Deploy Pages / Generate-Pages (push) Successful in 1m8s
2025-05-13 13:37:15 +08:00
11bc5921ab feat: update 2025-05-13 01:12:14 +08:00
bec3aaa73d
fix: update callback url & runnumber 2025-04-20 16:41:35 +08:00
59473df655
feat: add post
All checks were successful
Deploy Pages / Generate-Pages (push) Successful in 2m35s
2025-03-17 21:20:45 +08:00
3b98d64717
feat: add post
All checks were successful
Deploy Pages / Generate-Pages (push) Successful in 1m16s
2025-01-07 10:38:02 +08:00
84586658c6
fix: another italic not applying
All checks were successful
Deploy Pages / Generate-Pages (push) Successful in 37s
2024-11-22 16:52:02 +08:00
46434a38b6
fix: italic not applying
All checks were successful
Deploy Pages / Generate-Pages (push) Successful in 51s
2024-11-22 16:48:53 +08:00
005b97a96b
feat: add post
All checks were successful
Deploy Pages / Generate-Pages (push) Successful in 42s
2024-11-22 16:27:48 +08:00
13 changed files with 2087 additions and 4 deletions

View File

@ -26,7 +26,7 @@ jobs:
subject: 博客部署启动
to: 3145078758@qq.com
from: Gitea Actions
body: 博客自动化部署开始执行, 前往 ${{ gitea.server_url }}/Linloir/blog/actions/runs/${{ gitea.run_id }} 查看执行日志
body: 博客自动化部署开始执行, 前往 ${{ gitea.server_url }}/Linloir/blog/actions/runs/${{ gitea.run_number }} 查看执行日志
- 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.gitea.linloir.cn
run: curl https://upd.linloir.cn/update/blog.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_id }} 查看执行日志
body: 博客自动化部署执行失败, 前往 ${{ gitea.server_url }}/Linloir/blog/actions/runs/${{ gitea.run_number }} 查看执行日志

View File

@ -0,0 +1,773 @@
---
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调用BB中调用了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个通用寄存器+浮点寄存器)
- 内存开销每个协程至少2KBGo) 到 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甚至支持混合模式开发者应根据具体需求选择最合适的协程实现。

View File

@ -0,0 +1,418 @@
---
title: 问题定位回顾 - Windows 上发起 tcp 连接时提示 Only one usage of each socket address (protocol/network address/port) is normally permitted
date: 2024-11-22 11:52:37
tags:
- 工作
- 问题定位
categories:
- 技术
---
## 问题背景
某天在 Windows 宿主机上执行任务时,发现 wda 指令请求一直失败,查看日志发现唯一有效的错误日志是 `Only one usage of each socket address (protocol/network address/port) is normally permitted`,回顾宿主机环境在过去一段时间没有进行过变更,并且该问题是第一次出现,此前相同环境并没有出现过这个问题
尝试在其他宿主机以及本地开发机上执行相同命令均不能稳定复现该问题,其中 Linux 开发机无法复现该问题
![error info](/img/debug-windows-socket-drain/error_info.png)
## 定位过程
### 报错分析
由于报错信息只有一行,也只能从这个信息来入手。搜索引擎检索得到如下内容:
- StackOverflow: [How do I fix the error "Only one usage of each socket address (protocol/network address/port) is normally permitted"?](https://stackoverflow.com/questions/14654998/how-do-i-fix-the-error-only-one-usage-of-each-socket-address-protocol-network):文中指出,这个问题可能由多次 `listen` 同一端口引起,阅读提问者提供的代码确实有此问题
- StackOverflow: [HttpClient: Only one usage of each socket address (protocol/network address/port) is normally permitted](https://stackoverflow.com/questions/26428827/httpclient-only-one-usage-of-each-socket-address-protocol-network-address-port)*More likely, you are probably posting HTTP requests too often, and maybe not fully consuming the responses, which would prevent ASP from pooling and reusing connections and thus encountering port exhaustion over time*,该回答指出如果连接请求过于频繁、没有正确关闭,可能会导致端口耗尽
由于此前宿主机并没有暴露出该问题,优先考虑并不是发起请求及接收请求的 client 及 wda 侧的问题,即第一篇文章中提到的可能,而是从宿主机上的 tcp 连接状况入手来进一步分析这个问题,于是主要的关注点转向如下方面:
1. 问题出现时,宿主机上的 tcp 连接情况如何,是否存在异常,异常现象怎样?
2. 如果是 tcp 连接问题,为什么 Linux 机器无法复现该问题
### 链路分析
在开始检查 tcp 连接情况前,首先需要确认大致的请求链路,从而确定需要关注的 tcp 连接范围。问题发生在 Windows 上 docker 容器内服务向宿主机发送 wda 请求时,因此从这一条请求链路入手:
请求的末端是 usbmuxd 服务,该服务负责和连接的 iPhone 进行通信,所有请求最后都会发送到 usbmuxd 来进行多路复用 (multiplexing),从而达到在一个 usb 链路上同时执行多个请求。
usbmuxd 服务在 Linux 和 Windows 上的表现并不相同,在 Linux 上,其监听一个 UNIX socket 套接字来提供服务,而在 Windows 上,其监听 `127.0.0.1:27015` 端口提供服务,这里应该是处于安全考虑,其只监听了 `127.0.0.1:27015` 而不是 `0.0.0.0:27015`
由于 Windows 上的 usbmuxd 服务仅监听 `127.0.0.1:27015` 端口,对于容器内打出来的请求,考虑到其 ip 可能不是 `127.0.0.1`,遂在宿主机有起一个反代服务,从 `0.0.0.0:23333` 端口反向代理到 `127.0.0.1:27015`,这样不是以 `127.0.0.1` 发起请求的服务可以通过连接到宿主机的 `23333` 端口来和 usbmuxd 通信。在 Linux 上,同样有这个反代服务,不过它的作用是将请求代理到对应的 UNIX socket 上。
之后就是 docker 内的服务了,其通过 `host.docker.internal` 域名向宿主机的 `23333` 端口建立连接,并和 usbmuxd 通信来发送 wda 的请求。
这样,在宿主机上需要关注的连接就很明了了:
1. 所有直接向 `127.0.0.1:27015` 服务发起的通信,包括宿主机对 usbmuxd 服务的请求以及反代服务发起的请求
2. 向宿主机 `23333` 端口发起的通信
### tcp 链接分析
在 Windows 再次复现问题时,使用 `netstat -ano | findstr 27015` 以及 `netstat -ano | findstr 23333`,并辅助 `| Mesaure-Object` 方法来查看连接情况发现:
1. ESTABLISHED 状态连接数正常,差不多在十到百量级,符合当前宿主机负载情况
2. CLOSED 状态几乎没有
3. TIME_WAIT 状态非常多,平均在 8000 左右
上述现象佐证:
1. 宿主机中应该没有发生连接泄露的情况,连接泄露一般表现为 tcp 打开连接后并不正常关闭导致 ESTABLISHED 数量异常增多,此时 ESTABLISHED 状态数正常,故暂时排除这个原因
2. 存在高并发的短连接请求负载TIME_WAIT 为请求发起方正确通告服务端关闭 tcp 连接后进入的 2MSL 的等待窗口,该标记意味请求已经正常关闭,但是由于短时间内发起量太大导致大量 TIME_WAIT 状态连接堆积
联系 [报错分析](#报错分析) 中的第二篇参考文章,初步猜测可能由于存在进程大量发起请求,请求结束后 tcp 连接进入 TIME_WAIT 状态,由于没有设置 SO_REUSEADDR 此时端口号仍然处于被占用的状态,如果继续建立连接确实可能会出现端口号用尽导致报错。
这也就引导关注点到如下问题:
1. 20000 ~ 65535 这么多端口,怎么会这么快就用尽?`netstat -ano | findstr 27015 | findstr | findstr TIME_WAIT | Measure-Object` 或是 `netstat -ano | findstr 23333 | findstr TIME_WAIT | Measure-Object` 反映出来的 TIME_WAIT 连接也不到一万,问题是如何触发的?
2. 是哪个进程在大量发起这样的请求?是否符合预期?
### Windows 侧动态端口范围分析
先关注第一个问题,通过检索搜索引擎 *windows dynamic port range* 可以检索到如下文档:
[The default dynamic port range for TCP/IP has changed since Windows Vista and in Windows Server 2008](https://learn.microsoft.com/en-us/troubleshoot/windows-server/networking/default-dynamic-port-range-tcpip-chang)*To comply with Internet Assigned Numbers Authority (IANA) recommendations, Microsoft has increased the dynamic client port range for outgoing connections in Windows Vista and Windows Server 2008. The new default start port is 49152, and the new default end port is 65535*,也即 Windows 上的默认动态端口范围自 Windows Server 2008 开始,默认从 49152 开始到 65535一共 16384 个,同时使用 `netsh int ipv4 show dynamicport tcp` 可以查看实际配置的值
Powershell 执行 `netsh int ipv4 show dynamicport tcp`,确实得到 `16384` 这个结果:
```text
协议 tcp 动态端口范围
---------------------------------
启动端口 : 49152
端口数 : 16384
```
联系 [tcp 链接分析](#tcp-链接分析) 中的数量,如果与 `27015``23333` 端口建立的连接数量均达到 8000确实会有端口耗尽的可能。所以下一步的关键就是究竟是哪个进程在建立这些连接是否是自己工程逻辑有问题
### TIME_WAIT 源头分析
由于 `netstat -ano | findstr 27015 | findstr TIME_WAIT` 无法展示实际创建连接的 PID为此我专门写了一个 python 小脚本,这个脚本不断获取非 TIME_WAIT 状态的连接列表来获取连接的 PID再获取 TIME_WAIT 连接并找到它们原先对应的 PID 输出:
```python
import subprocess
import time
import signal
import sys
# Dictionary to keep track of dual tcp ip:port pair to pid
tcp_map = {}
def update_tcp_map():
global tcp_map
# Execute the command to get established connections
command = "powershell -Command \"netstat -ano | findstr /V TIME_WAIT\""
result = subprocess.run(command, capture_output=True, text=True, shell=True)
if result.returncode == 0:
lines = result.stdout.strip().split('\n')
for line in lines:
parts = line.split()
if len(parts) >= 5:
local_address = parts[1]
remote_address = parts[2]
pid = parts[4]
tcp_map[(local_address,remote_address)] = pid
def check_time_wait_sockets():
global tcp_map
# Execute the command to get TIME_WAIT sockets
command = "powershell -Command \"netstat -ano | findstr TIME_WAIT | findstr /V :443 | findstr /V :80\""
result = subprocess.run(command, capture_output=True, text=True, shell=True)
if result.returncode == 0:
lines = result.stdout.strip().split('\n')
for line in lines:
parts = line.split()
if len(parts) >= 5:
local_address = parts[1]
remote_address = parts[2]
pid = parts[4]
# Check if the TIME_WAIT socket was previously tracked
if (local_address,remote_address) in tcp_map:
print(f"TIME_WAIT socket: {local_address} -> {remote_address}, previously tracked PID: {tcp_map[(local_address,remote_address)]}")
# Remove the tracked record
del tcp_map[(local_address,remote_address)]
def signal_handler(sig, frame):
print("\nExiting...")
sys.exit(0)
def main():
signal.signal(signal.SIGINT, signal_handler)
print("Monitoring TCP connections. Press Ctrl+C to exit.")
while True:
update_tcp_map()
check_time_wait_sockets()
if __name__ == "__main__":
main()
```
脚本写的比较简单,考虑到没有太多干扰连接,直接抓取的所有的 TIME_WAIT 连接的原始 PID同时过滤了下 `443``80` 这种明显干扰的结果。
同时为了分析 [报错分析](#报错分析) 中的第二个问题,同样也写了一份针对 Linux 机器的分析脚本:
```python
import subprocess
import time
import signal
import sys
# Dictionary to keep track of dual tcp ip:port pair to pid
tcp_map = {}
def update_tcp_map():
global tcp_map
# Execute the command to get established connections
command = "netstat -anolp | grep -v TIME_WAIT"
result = subprocess.run(command, capture_output=True, text=True, shell=True)
if result.returncode == 0:
lines = result.stdout.strip().split('\n')
for line in lines:
parts = line.split()
if len(parts) >= 7:
local_address = parts[3]
remote_address = parts[4]
pid = parts[6]
tcp_map[(local_address,remote_address)] = pid
def check_time_wait_sockets():
global tcp_map
# Execute the command to get TIME_WAIT sockets
command = "netstat -anolp | grep TIME_WAIT"
result = subprocess.run(command, capture_output=True, text=True, shell=True)
if result.returncode == 0:
lines = result.stdout.strip().split('\n')
for line in lines:
parts = line.split()
if len(parts) >= 7:
local_address = parts[3]
remote_address = parts[4]
# Check if the TIME_WAIT socket was previously tracked
if (local_address,remote_address) in tcp_map:
print(f"TIME_WAIT socket: {local_address} -> {remote_address}, previously tracked PID: {tcp_map[(local_address,remote_address)]}")
# Remove the tracked record
del tcp_map[(local_address,remote_address)]
def signal_handler(sig, frame):
print("\nExiting...")
sys.exit(0)
def main():
signal.signal(signal.SIGINT, signal_handler)
print("Monitoring TCP connections. Press Ctrl+C to exit.")
while True:
update_tcp_map()
check_time_wait_sockets()
if __name__ == "__main__":
main()
```
分析脚本的输出,发现:
1. 在 Linux 上,几乎所有的 TIMEWAIT 请求从容器内发出,并与宿主机的反代建立连接。
2. 在 Linux 上单容器任一时刻均会存在约 1000 TIMEWAIT 请求,这一数量与 Windows 上基本保持一致。
3. 在 Windows 上TIMEWAIT 分为几乎数量完全相同的两个部分:
1. 由 `127.0.0.1` 发起的与反代监听端口建立的连接PID 查看发起进程为 `com.docker.backend.exe`
2. 由反代进程发起的,与 usbmuxd 监听端口建立的连接
其中第一点符合预期,因为在 Linux 上反代通过 UNIX socket 与 usbmuxd 通信,并不经过 tcp 连接。
第二点则提示工程确实存在冗余逻辑1000 请求量在 120s TIMEWAIT 窗口下代表约 8qps 请求负载,这明显存在逻辑错误,自动化再怎么跑也不应该出现如此高的请求负载。
第三点令人感到疑惑:为什么从 docker 容器内发出的请求实际发起 ip 为 `127.0.0.1`,并且是由 docker 进程发起的?
由于 Linux 下,请求的发起 ip 均为 docker bridge 网卡下容器的 ip可以推断 Linux 下容器内发起的请求不会占用宿主机的动态端口。而如果 Windows 会由 docker 代为发起请求,从现象上看起来确实是会占用宿主机的端口范围,这很有可能是 Linux 上无法复现这个问题的,除了 Linux 上与 usbmuxd 通信不使用 tcp 连接之外的另一个关键原因。
至此,可以产生一些阶段性的结论:
1. 工程逻辑一定是存在问题需要优化8qps 请求压力不合理。
2. 问题大概率是由于端口耗尽问题引起Linux 由于 docker 容器网络与 Windows 原理不同不占用宿主机端口,同时与 usbmuxd 也不占用端口,因此不存在这个问题。
接下来,更多的是验证上述结论的正确性,目光转向下列方向:
1. 如果确定是端口耗尽导致的问题,手动构造场景是否可以复现这个问题?
2. 工程是否真的有问题?因为也有可能是发起请求的组件存在问题。
3. bridge 模式在 Windows 上发起请求时的行为是否真的与上述一致?是否可以复现?是否有文档支撑?
### 问题复现
有了猜想,想要复现问题其实比较容易,[Windows 侧动态端口范围分析](#windows-侧动态端口范围分析) 中提到Windows 默认动态端口范围就是 16384 个,为了减少额外操作干扰,就不改动这个范围,直接手动打 16000 个连接看看问题能不能复现即可。
遂随便手搓了一对 server-client 脚本server 甚至还有没办法退出的 bug不过管他呢反正也是复现问题用的能跑就行
client 可以手动通过 `open <number>``close <number>` 指定打开或关闭多少个连接,从而达到精确控制,免得开得不够多或者开得太多,主打一个灵活。
```python
"""server"""
import socket
import threading
def handle_client(client_socket):
"""Handles a single client connection."""
try:
while True:
# Keep the connection alive
data = client_socket.recv(1024)
if not data:
break
finally:
client_socket.close()
def start_server():
"""Starts the TCP server."""
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(('0.0.0.0', 23335))
server.listen(5)
print("Server listening on port 23335...")
while True:
client_socket, addr = server.accept()
print(f"Accepted connection from {addr}")
client_handler = threading.Thread(target=handle_client, args=(client_socket,))
client_handler.start()
if __name__ == "__main__":
start_server()
```
```python
"""client"""
import socket
import sys
class ConnectionManager:
def __init__(self):
self.connections = []
def open_connections(self, number):
"""Open a specified number of TCP connections."""
for _ in range(number):
try:
client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# client_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # Allow reuse of the address
client_socket.connect(('127.0.0.1', 23335))
self.connections.append(client_socket)
print(f"Opened connection {len(self.connections)}")
except Exception as e:
print(f"Failed to open connection: {e}")
def close_connections(self, number):
"""Close a specified number of TCP connections."""
to_close = min(number, len(self.connections))
for _ in range(to_close):
client_socket = self.connections.pop()
client_socket.close()
print(f"Closed connection, {len(self.connections)} remaining")
def listen_for_input(connection_manager):
"""Listens for user input to open/close connections."""
while True:
user_input = input("Enter command (open <number> / close <number>): ")
parts = user_input.split()
if len(parts) != 2:
print("Invalid command. Use 'open <number>' or 'close <number>'.")
continue
command, number_str = parts
try:
number = int(number_str)
except ValueError:
print("Please enter a valid number.")
continue
if command == "open":
connection_manager.open_connections(number)
elif command == "close":
connection_manager.close_connections(number)
else:
print("Unknown command. Use 'open' or 'close'.")
if __name__ == "__main__":
connection_manager = ConnectionManager()
try:
listen_for_input(connection_manager)
except KeyboardInterrupt:
print("\nExiting client.")
for conn in connection_manager.connections:
conn.close()
sys.exit(0)
```
测试发现,当打开了 16000 个端口左右再全部 close 后,问题确实可以复现。
为了交叉验证,在等待一段时间 (大概相当于第一个 open 的端口关闭 2 分钟后)问题自行消失并且在不另开新连接时不再复现。
同时,还通过设置 `SO_REUSEADDR` 标记进一步验证是因为 TIME_WAIT 端口无法被复用导致的问题在设置该标记后TIME_WAIT 阶段的端口可以被新的连接复用,实际测试反复开关连接超过 16000 次仍让不会导致问题复现。
至此,基本可以肯定问题出现的原因就是短连接过多导致 TIME_WAIT 堆积,最后动态端口耗尽无法创建新的连接使得 wda 请求失败。
### 工程问题排查
由于此处与工程相关逻辑强相关,不详细展开。经过代码 review发现部分环节存在多次反复获取同一信息的问题即有一些连续执行的逻辑链路 `A-B-C-D-E`,其中每一步都会请求 `get_necessary_data` 方法,由于五个方法分开实现,串联五个方法的代码作者与方法编写人又各不相同,没有注意到这里方法的反复多次冗余调用,最终导致了短时间高频调用的逻辑。暂时通过请求缓存优化后得以解除。
此处也佐证了高频的 wda 请求确实与工程的逻辑相关,进一步排除了底层工具链存在问题的可能性。
### Docker 在 Windows 下的网络驱动行为
在搜索引擎检索相关内容,发现并没有太多相关的内容,个人也没有找到官方有关上述发现的行为相关的文档。通过将 [问题复现](#问题复现) 中的 client 代码搬到 docker 容器内执行并请求 `host.docker.internal` 地址,可以复现这种行为,也即容器内与 docker 网卡网关地址建立连接,宿主机上由 docker 发起另一段连接,并不直通,而在 Linux 上执行则可以看到不会有这样的行为。
在 Windows 上执行 `ipconfig` 同样也可以看到不会有 Linux 下执行 `ip a` 所看到的 `docker0` 或是 `vethX` 网卡,进一步推测两个平台上 docker 对于 bridge 网络驱动的实现方案不相同。
同时,我也对 gpt 进行了相关问题的询问,下面是 gpt 给出的解答,大家参考着看,不具有权威性:
```text
[Prompt]
I'm using docker on Windows, and I'm aware of a weird behavior:
When I use host.docker.internal to access the host port, say, I try to connect to a tcp server listening on 0.0.0.0:23333 on host machine, I open a socket inside the container with the container ip:port as one end and host.docker.internal:23333 as the other.
However, when I check the open connections on host, it shows that the just opened socket has 127.0.0.1:another port as on end and 127.0.0.1:23333 as the other. It seems that docker act as an intermediate, accept the connection to host.docker.internal and reopen one on host. Is this real and expected? Why is this behavior different than that on linux?
Also, I've checked ipconfig on Windows and ifconfig on WSL, I can't see any additional interfaces like `veth` or `docker0` or `br-xxx` as these interfaces exists on Linux
------
[Answer]
Yes, your observation about Docker's behavior on Windows is correct and expected due to the differences in how Docker is implemented on Windows compared to Linux.
### Docker Networking on Windows vs. Linux
On Linux, Docker uses the host's kernel features directly, such as namespaces and iptables, to manage containers. It creates virtual network interfaces (like `veth` pairs), bridges (`docker0`), and uses the native Linux networking stack. This setup allows containers to communicate with the host and each other in a manner that closely resembles how separate physical machines would interact on a network.
On Windows, Docker operates differently because Windows does not natively support the same kernel-level features as Linux. Docker on Windows has two main modes: using Hyper-V (with Docker Desktop) or using the Windows Subsystem for Linux (WSL2). Each of these modes handles networking in its own way:
1. **Hyper-V**: When using Docker Desktop with Hyper-V, Docker runs containers inside a lightweight virtual machine. All Docker networking goes through this VM. The `host.docker.internal` DNS name resolves to an internal gateway IP that Docker sets up to route traffic from the container to the host. This is why you see connections appearing to come from `127.0.0.1` on different ports; Docker is essentially NATing (Network Address Translation) the traffic between the container and the host.
2. **WSL2**: In this mode, Docker utilizes a utility VM that runs a real Linux kernel, and Docker containers are managed much like they are on a native Linux system. However, the networking is still handled through a translation layer between Windows and the Linux kernel in WSL2, leading to similar behaviors as seen with Hyper-V.
### Why the Difference?
The primary reason for this difference is the underlying OS architecture and the lack of native container support in Windows. Docker must rely on virtualization and network translation to enable similar functionalities, which inherently changes how networking behaves.
### Lack of `veth`, `docker0`, etc., on Windows
As you've noticed, network interfaces like `veth`, `docker0`, or `br-xxx` are not visible on Windows. This is because these interfaces are part of the Linux networking architecture, which Windows does not natively support. Docker on Windows uses other methods (like creating a virtual network inside Hyper-V or leveraging the network stack in WSL2) to handle container networking.
### Conclusion
The behavior you're seeing is expected given the architectural differences in how Docker operates on Windows versus Linux. Docker must adapt to the underlying operating system's capabilities, leading to different networking setups and behaviors. If you need more consistent behavior across different OSes, you might consider using Linux VMs on Windows or deploying your Docker containers on native Linux systems.
```
## 总结
问题的根因是工程逻辑变更后请求次数异常增加导致直接原因是由于请求次数大量增加后Windows 上动态端口耗尽触发了该报错同时docker 在 Windows 上的网络行为也进一步加剧了请求数量增长的影响(导致请求数量翻倍)。
由于 usbmuxd 和 docker 在 Windows 和 Linux 上行为的差异性导致了这个问题在 Linux 机器上无法复现,同时由于同一时间宿主机上任务负载量不同,导致了在同一个宿主机上也不能够稳定复现。通过手动构造极端场景可以做到稳定复现。
## 解决方案
找到了问题的原因,解决方案也就很明了了:
1. 优先级最高的就是直接干掉工程内部的逻辑了,通过优化掉工程的错误调用可以快速解决目前问题
2. 考虑到后面宿主机仍有负载增长空间,代码编写也仍然不一定完美,可以通过调整 Windows 动态端口范围和直接从容器内访问 usbmuxd 服务来将允许的请求并发量提高大约 4 倍。
此处额外解释一下第二点中为什么又可以直接从容器内访问 usbmuxd 了:前面发现容器内请求 `host.docker.internal` 会让 docker 以 `com.docker.backend.exe` 的身份使用 `127.0.0.1` 发起一个代理的请求,这还正好满足了 usbmuxd 只监听 `127.0.0.1:27015` 的行为了,这样宿主机的反代服务也就不需要了。
对于 Linux 服务,如果需要保持架构上的统一,一个可能的优化方向是将 usbmuxd 监听的 socket 映射到容器内?考虑到 docker daemon 的 socket 可以做到,大概这个方向也可行,但目前还没有做尝试,就留作一些可行的方案放在此处吧~

View File

@ -0,0 +1,32 @@
---
title: 年终总结 - 2019
date: 2019-12-30 23:47:36
tags:
- 年终总结
- 自言自语
categories:
- 杂思
---
  还有24h2019就结束了
  本来想说今年是过去17年中最糟糕的一年
  但仔细想想,大概之后的每一年都会是这样,没什么意思,所以也不想写很多情绪化的东西,就随便说说好了。
  
  我想,这一年我学到的唯一的道理,就是
  “在学校之外的很多事情,自有他们发展的规律。有很多事情,努力除了能让自己不后悔以外,什么也改变不了。”
  
  今年第一次真正认识到人的渺小在意外、灾难、疾病面前人的力量是薄弱的甚至连一点挣扎的机会都没有。在无锡侧翻的高架桥下的人也许永远也料不到自己会被180吨的轧钢要去了性命重庆广场305楼下的两名学生也绝不会想到自己会因为一具没了灵魂的躯壳而永远停留在17岁。就像任何人都无法躲避死亡我们所有的观念行为无非是在改变着我们走向终点的路途罢了。
  
  我自己的经历也不例外。我以为自己可以做到,我以为只要我倾注足够的精力,我祈祷,我等待,事情就可以有所改变,甚至还凭着侥幸心理还在知乎上写了一篇回答,给别人讲方法,提帮助。
  就像在用沙子搭积木,一粒一粒地把沙子垒起来,注水,看它们黏在一起,即便我知道当水全部蒸发的时候,所有的努力都会化为泡影,但我不信这个邪,我骗自己,只要我不断地修补、维护,水就永远不会蒸干,于是一眨眼便是三年。
  但突然有一天,一阵风吹过,带走了不起眼角落里最后一滴水,什么也没有了。
  
  曾经看过一个理论,在高维的空间里,时间像长、宽、深那样是既定的,也就是说我们现在经历的,过去发生的,和即将发生的一切,都是存在的。
  我想了很久,难道这不就意味着混吃等死吗?
  也还是有一些差别的。对于处于三维的我们,未来之所以是未来,正是因为它的不可预见性,就像看不到的立方体的背面那样。即使我能猜到它背面的样子,即使翻转它不会对它有一分一毫的改变,但我仍然对它抱有好奇,好奇和我想象的有什么差别。
  努力也许改变不了结果,但努力的过程会创造回忆。
  站在2019的终点看之前的365个日夜有开心有难过有绝望。我看到自己因为一句话而高兴又因为一些事而失落我看到自己疯癫的样子也看到自己沉默的样子我看到一些人因为我而高兴也看到一些人被我波动的情绪所影响...所有的这一切构筑了我来到这世界第17年的回忆我的2019年。
  “如果我什么也改变不了,那我就在这个世界里创造一些回忆吧。”
  (正文完)
  
  P.S.过去的一年因为自己的原因情绪波动很大,很多时候都因为失控没有去注意正常的情绪表达,多多少少都对身边的人产生了影响。借此机会,我希望向很多人道歉,真的对不起。

View File

@ -0,0 +1,19 @@
---
title: 年终总结 - 2020
date: 2021-01-11 23:59:02
tags:
- 年终总结
- 自言自语
categories:
- 杂思
---
准切地说,这篇年终总结来得有些晚了,晚了一个月十一天二十三小时五十九分钟。但过去的这一年是那么的不可思议,那么的跌宕起伏、刻骨铭心,倘若能用一篇年终总结给这样的一年画上一个句号,便是再好不过的了。
公元贰零贰零年,我来到这个世界的第十八年。年初,武汉疫情爆发,所有的一切都仿佛按下了暂停键,电影取消了,商场关闭了,学校放假了,就连那么多年来从未改变的高考时间也史无前例地推迟了。在这些与自己独处的漫长日夜里,很难得地,我慢慢开始和自己交流,和过去交流,执念被慢慢放下,伤痕开始逐渐愈合,记忆的回声终于不再萦绕,一切都在一个梦醒的早晨恢复正轨。年中,高考是生活赠与我的第一个成人礼,高考结束后的第二天,我结束了一个人面对生活的日子,我有了陪伴我、鼓励我、支持我、给我惊喜的女朋友,我收获了让我感到温暖而幸福的爱情,这是生活赠与我的第二个成人礼。第三个成人礼,是中大录取的通知讯息。当我看到短信的时候,我想到了过去三年我做的所有的事情,我想到了高考后的不甘,想到了她,想到了未来,我之后所有的生活都在那一刻,因为那一个短信而走向了一个崭新的方向。年末,我来到了大学,遇到了和蔼的老师,友好的舍友,我犹记得每一次和所爱之人见面时那种溢于言表的喜悦与激动,犹记得期末周复习的紧张与担心。而这个学期所有的故事,也都以虽不差但仍不尽如意的期末成绩画上了完整的句号。
过去这一年我经历了悲伤感受了快乐收获了爱情也开始了我成年后的第一年人生。2019快结束的时候我认为生活已经不能更糟了然而2020开年时便驳倒了我这样的想法。但过完整整一年虽然生活在很多时候变得更糟了但在更多的时候变得更加地与以往不一样了——我们认识到生命的宝贵看到信仰的光芒与责任的力量我们开始与身边的人联系起来我们和家人们一起说话和朋友散心这一切在2020都显得那么不一样。
可以说,从去年开始,我才真正开始生活。
转眼就要离开鼠年,希望我们所有人都能在新的一年,再出发。

View File

@ -0,0 +1,51 @@
---
title: 年终总结 - 2021
date: 2022-01-01 00:03:12
tags:
- 年终总结
- 自言自语
categories:
- 杂思
---
很快啊,又是一年过完了。
不知道什么时候开始,感觉一年的长度开始越来越短,短到年初念叨的想见的人到了年末也还是没有见,短到年初开始背的六级单词到了年末也没背下几个,短到年初那些信誓旦旦在今天看来不过又是明年一定...
如果要用一个词来描述2021我认为“改变”是最贴切不过的。
年初的时候,确诊了反流性食管炎。
本来以为会像以往的胃疼那样不过是短时间生活习惯的影响,过一阵子就会自己康复,但没想到即便是做了胃镜开了药也仍然没有阻止它一直折磨我到今天。为了缓解这个问题对生活的影响,很多习惯都不得不改变,其中就包括戒掉我喝了六七年的奶茶和几乎所有的甜食。
到了年中,就是转专业。
本是父母随心的一问,却没想让本来准备努力保研遥感的我重新思考了转专业的可能性,并在截止日期当天提交了申请。
在转专业的过程中发生了很多其他的事情,并且还要同时准备着转专业失败可能面临的绩点断崖下滑以及忍受每晚胃病的折磨,使得那一个月的每一天都无比难熬。
也正是在这个过程中我改变了对曾经很亲密的人的看法,并最终选择了去和平结束上一段关系。
除了这些明显的生活上的改变以外,今年更多的是一些想法和看法上的改变。
我曾经以为,我身边的好友尽管可能会对一些有争议的事件持有不同的看法——我始终认为这一点十分正常并可以理解,但至少大家在发表自己观点的时候会是理性的、客观的或最起码是有理且有据的。
但当我看到曾经标榜自己理智的人在社交媒体上发出匪夷所思的话语,当我还没开口聊就被曾经亲近的好友一句“别说了,再说我就要打拳了”这一原话给堵住的时候,当我提到我希望能付出更多精力在学业上以能够有能力会报父母却得到“我不能理解”的答复时
我感到的不仅仅是惊愕、诧异,更多的是失望和害怕,对他们而言,事件不再是一个被客观评价的现象和引发人思考的主体,而是变成被抒发自己偏激想法的载体。
于是我选择了改变,选择了去远离和保持沉默,远离那些匪夷所思的看法,并避免自己成为这样的家伙。
今年改变的同样也有着对事的看法。
无论是对未来的规划,还是对专业的理解,甚至是平日里的目标,都随着转专业的完成被全部刷新。
在2021我看到了更多我所没有学习的知识看到了更多厉害的人看到了未来更多的可能性。
当然,过去的一年远远不止这些
第一次拿到奖学金(说不定是最后一次)
第一次在b站达到单日上万的播放量
第一次收到实习邀请......
所有的这些都发生在这过去的365天里都成为了构筑我2021的片段。
今天过去的一年已成定局新的一年就在我写下这些文字的时候就已然悄无声息地开始。如果要说对2022有什么展望的话...
我希望,
新的一年,
我和我所爱的所有人,
平安,健康,快乐。
就这样,就够了。
最后,
感谢过去的一年有你们陪伴。
真的,
谢谢你们。

View File

@ -0,0 +1,39 @@
---
title: 年终总结 - 2022
date: 2023-01-01 00:00:00
tags:
- 年终总结
- 自言自语
categories:
- 杂思
---
2022.12.31 21:00
今年只剩下一场电影的时间了。
过去一年,如果要用一个词语概括的话,那我想应当是“遇见”吧。
其实,关于这个词语,在过去的一年里的无数个瞬间,我都有过构想。
之所以选择“遇见”,因为遇见有着往前走的意思,当我们没有再停留在原地的时候,当我们不在回头看的时候,才会看见那些原本看不见的风景,遇见那些原本碰不到的人们。
过去的一年里,我确实有在向前走,如果更加贴切一些地说,应当是在向前狂奔。
年初的时候我终于决定选择试着加入Matrix的大家庭这个决定成为了我今年做得最正确的决定之一。在Matrix我遇见了真正优秀有梦想和追求的人们我看到了与宿舍舍友待白昼如黑夜迥然不同的生活态度我开始寻着前辈的步子发展自己热爱的方向向着自己曾经最仰慕的样子进步。
接着,我遇见了一个温柔,耐心而又体贴的女生。最初的遇见,本以为只会是寥寥几语就会最终沉没在微信记录中的一面之缘,但完全出乎意料地,这一面之缘让我们互相都成为了对方生命中最重要的人之一。这一次遇见,不但让我两点一线的生活再一次有了色彩,更是给过去的一年里留下了无数令我难忘的回忆。
在今年的五月份,我还在知乎收到了一份很长的留言,留言那头是我的初恋,留言是关于我曾经的一篇有关她的知乎回答。在我们分手后的很长一段时间里,那篇回答都是被我删除的状态,直到今年三月份的时候,我决定正视和放下我的过去,于是恢复了那一篇回答,她看到了,特地来感谢我。留言里讲述了一些分手以后的事情和她的近况,她也有了新的生活,新的恋人,希望我不要再因为过去而责怪自己。读完留言是泪流满面,三年过去了,大家都没选择憎恨对方,都没有留在原地,而是都踏入了新的环境,新的生活。
再后来我搬到了D502在那里遇见了后半年玩的最多的一只吉祥物开启了接下来几乎大半年的实验室生活。D502可以说见证了我过去大半年的脚步见证了我许多个难忘的项目我的操作系统、计网小作业和我的图形学大作业等等等等。
过去的一年,有无数次遇见,每一次遇见,都在提醒我,我在向前走。
2022.12.31 23:00
我用这最后一场电影的时间看了一场关于我自己的2022年的回忆录。
我很感谢我自己,没有沉沦,没有停留,也许有时迷茫,但一直在向着远处的微光前行。
我很感谢我的朋友们,在我低落的时候支持我,生病的时候关心我,你们做了作为朋友最应该做的事情,遇见你们是我的荣幸。
我很感谢我的女朋友,在我失落的时候愿意倾听我的苦恼,在我需要陪伴的时候愿意陪我出去散心,很多时候你是我精神上最有力的支持,是于我而言最重要的存在。
我很感谢实验室的吉祥物和实验室的伙伴们让我过去一年学到了很多也让我在D502的生活充满了有趣的回忆鞠躬。
感谢2022带给我的成长回忆。
……
接下来把时间交给2023我们拭目以待。

View File

@ -0,0 +1,72 @@
---
title: 年终总结 - 2023 至 2024
date: 2025-01-01 01:15:49
tags:
- 年终总结
- 自言自语
categories:
- 杂思
---
是因为太久没有写过叙事风格的文字,还是因为脑子早已经被快节奏的短视频侵蚀,又到了年末回顾的时候,我竟与面前的电脑周旋了一晚,最后愣是一句能读的话都没有写出来。末了,把写不出东西这个毛病归结于家里的环境缺乏情调,次日专门找了个海边的图书馆来写,结果到了太阳都下山了,才想起来自己此行的正事不是来这儿看书,而是写点什么。倒是歪打正着,好不容易来一趟,总得在关门之前写下点什么,遂匆匆留下此篇,作为我过去两年存在过的痕迹。
2024 年的终曲轻轻落幕,与我二十一和二十二岁的许多事情一样,没有轰轰烈烈的收场,只是在那某一瞬间突然意识到,过去的时光就这么结束了。
是什么时候开始觉得时间过得很快的呢?
是那一天想喝家门口的喜茶,却发现它几个月前就已经搬走的时候吗?
是那个实习结束后的早晨伸着懒腰起床,打开手机看见日历上赫然写着 10 月的时候?
是考完研的那天午后,抱着沉沉的资料最后一次走出图书馆的瞬间?
是意识到自考研后又已经过去了两个 75 天的那天?
还是一转眼发现就连 2024 都已经过完的今天?
已经记不太清了。
只觉得像是一个阳光灿烂的下午,躺在阳台上被太阳晒得暖暖的椅子里眯上了眼睛。在梦里,时间很慢,慢到周一到周五的时间像有一个学期那么长,床头的耳塞堆成了小山,书页翻得泛黄。醒来的时候,冬日的阳光还照在脸上,窗外的鸟儿还在叽喳,楼下的商铺喇叭还播着广告,对面小区的女人也还在遛她的狗,一切都好像没变。只是家里客厅的台历不再写着 2023楼下不再有开了两年的排骨饭对面小区的萨摩也再没看到了。
马尔克斯说,“生活不是我们活过的日子,而是我们记住的日子,我们为了讲述在记忆中重现的日子”。
2023 与 2024 ,是 “寻找” 的两年。
我去实习,去工作,去寻找那个从小就向往的大人的世界,去争取那个看起来还算不错且稳定的生活。而我同时害怕,害怕未知带来的不确定性,便又去逃避,去考研,去努力开拓那条曾经未曾想过的道路。为了毕业,我还去试着做了一些研究,去学习,去尝试在未曾涉足的学术领域中寻找自己的兴趣与潜能。
寻找是创造的过程,但也因此注定是选择与放弃的过程。过去的这两年里,我时常会想到一首诗:
黄色的树林里分出两条路,
可惜我不能同时去涉足,
我在那路口久久伫立,
我向着一条路极目望去,
直到它消失在丛林深处。
但我却选了另外一条路,
它荒草萋萋,十分幽寂,
显得更诱人、更美丽,
虽然在这条小路上,
都很少留下旅人的足迹,
虽然那天清晨落叶满地,
两条路都未经脚印污染。
啊,留下一条路等改日再见!
但我知道路径延绵无尽头,
恐怕我难以再回返。
也许多少年后在某个地方,
我将轻声叹息把往事回顾,
一片树林里分出两条路,
而我选了人迹更少的一条,
因此走出了这迥异的旅途。
还记得在发出放弃考研录取邮件的那个下午,我仿佛就站在这样的岔路口前,极目远眺。我很明白,这不同于我过去二十年人生中的许多选择,这一次没有明确的答案,也没有周旋的余地。
第一次,我意识到,放弃并非选择的反面,而是彼此纠缠,同时发生的一对结果。选择了一条路,便意味着永远失去了踏足那另一条的权利。
最终按下发送键的那一刻,我恍惚间感到,那些考研的日子似乎就像写满了字的旧报纸,在刹那间被撕得稀碎。
过去的日子,就一文不值了吗?我问自己。
想起学马原时徐涛老师对认识的反复性和无限性的讲解:人的认识能力是不断提高的,有的我们认知的局限性决定了我们没有办法看到事物的本质。
可以说,是考研的过程改变了我,是这些日夜饱满了我的人生和我的经历,从而让我能够更清醒地认识我所想要的人生到底是什么样的。也就是说,宣告考研死亡的不是我,当我下定决心开始的时候,它就注定是会灭亡的。是那些日夜带给我的,最终否定了它们自己。
虽然,此生再无机会走上那未曾走过的路,但那些为之准备的日夜,我想,将会化作我的行囊,带着我走向路的更深处。
那,选择另一条路,就会更好吗?
我永远也不会知道,在平行世界里,选择另一条路的自己,到底会不会过得更好。但可以肯定的是,在往后的日子里,在现在的道路上,我会好好过。
2025向前走别回头。

View File

@ -0,0 +1,73 @@
---
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 即可

View File

@ -0,0 +1,606 @@
---
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
}
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

View File

@ -284,7 +284,7 @@ aside:
enable: true
icon: fab fa-github
text: Follow Me
link: https://github.com/xxxxxx
link: https://github.com/Linloir
card_announcement:
enable: true
content: This is my Blog