问一个协程方面的问题

2021-12-13 15:04:45 +08:00
 kingofzihua

协程究竟解决了什么问题,都在吹协程,像是 go 、kotlin 都有协程,java 本身没有协程,

还有相比于线程,协程的优势是什么,为啥 java 没有协程,性能框架和 go 框架不相上下,协程这么牛逼为啥 rust 没有内置协程(听说都已经写好了,但是不符合理念,没合并)

操作系统调度的时候是以线程为单位调度的,又不知道协程的存在,即使你用协程了,操作系统还是只知道你是线程啊。

各种博客,都只说了比线程更轻量,占用内存小,切换成本小,但是线程切换是操作系统决定的,系统切换线程,你协程也没用啊。

各位大佬,救救孩子吧。

13032 次点击
所在节点    Linux
155 条回复
lxdlam
2021-12-17 13:29:39 +08:00
@statumer 这是显然的强加因果。

网络编程的崛起之于协程已经是很早的事情了,实际上,nginx 和 Redis 都是单线程 IO 模型( Nginx 1.17 和 Redis 6 才支持的多线程网络 IO 模型),性能并不比后面很多所谓用了多线程 or 协程的应用差,那么协程在这里的作用是什么?从另一个角度,OpenResty 往 Nginx 里面嵌入了一个 LuaJIT ,底层 IO 逻辑完全没变的情况下,为什么比 Nginx 性能好那么多?这些和协程崛起相关没有任何直接联系。

关于二三点,既然你已经提到了 go ,为什么不去看看 go 的源码实现和 Scheduler 设计? Design Doc ( https://docs.google.com/document/d/1TTj4T2JO42uD5ID9e89oa0sLKhJYD0Y_kqxDv3I3XMw/edit#heading=h.c3s328639mw9 )和 1.17 的源码 ( https://cs.opensource.google/go/go/+/refs/tags/go1.17.5:src/runtime/proc.go;bpv=1;bpt=0 )都说明了 Go 的工作方式正是我所说的在多核系统下,会用另一个线程去跑 blocking syscall ,对于你的程序来说,这部分 syscall 就是 non-blocking 的。同样在 Java 最新的协程方案 Project Loom 里面,工作方式也是一样的 ( https://blogs.oracle.com/javamagazine/post/going-inside-javas-project-loom-and-virtual-threads )。特别要提示一点,虽然现在 Go 确实在可以 poll 读文件的情况下使用 epoll ,但是在 bsd 环境下 Go 现在还是使用 blocking syscall ,而更早的 Go 版本(我没记错是 1.12 以及之前)同样使用的 blocking 的文件 syscall 去读文件。

实际上,如果你真的对所谓的“只是对有无调用栈的取舍”做出了理解的话,你就不会陷入这种强加因果的关系。对于传统的线程,我们会有两种程度的开销:一个是线程自身占用 memory 的开销,这个和系统的默认栈大小有关,这导致了我们的内存占用会随着线程申请数量,这个线程自身的申请也需要通过 syscall ,时间和内存资源占用都存在 overhead ;另一个角度,虽然根据机器和系统不同数据有差异,但是普遍来说,native thread 切换的开销并不小,对于 常用 Linux 发行版来说,这个切换通常不少于 1ms 。当我们实现了用户侧调度,我们可以通过巧妙地 GC 管理等减少这种异步结构的申请和释放开销,通过剔除和当前应用无关的系统 OS 数据字段同步达到更快的上下文切换,甚至直接基于状态机模型舍弃这部分开销,得到普遍更好的执行时间。更进一步来说,OS Level 的调度是 generic 的,内核针对的是任何任务的调度,而对于我们的应用,我们和 runtime 更容易知道我们的调度侧重点和优势,能够把时间和优先级排布做的更精妙,自然能够带来一定的性能提升,这才是用户侧调度的意义所在,也是协程这类结构的性能提升所在。

我同意异步的 IO 会比起同步 IO 有性能提升,虽然平台不同变量较大,但在普遍比较下 IOCP 确实性能和系统利用率是强于 epoll 的,io_uring 确实是未来的方向。但是这部分的性能提升和线程以及协程本身开销优化没有任何关系,假如我们认为线程和协程切换都是零开销的话,那使用哪种结构都影响不了在同一种 IO 下的调度开销。顺带补充一点,线程的上下文切换和调度也是要 involve syscall 的,用户侧实现调度的话能够减少这些 syscall 的次数。

最后,异步是一个很复杂的概念,但是我们可以抛弃一些严谨性的情况下认为异步就是并发。对于并发来说,根据你观察的 level 不同,多进程对于操作系统来说是并发的,多线程对于应用是并发的,而我多次提到,所谓的协程就是一种用户调度抽象,实际上和多线程相比就是执行模型的差距,本质上和多线程没有区别。

最后,如果你认为在当时文章里描述的 LWP 和 Linux Thread 的调度没有任何区别的话,那可能不求甚解的是你。Java 1.3 是 2002 年发布的,而 Linux 真正实现了内核可抢占是在 2003 年的 2.6 版本。由于 1:N 的设计,当某个 task 开始调用 blocking syscall 的时候,其他的所有 task 都没有机会被唤醒,更别提调度到其他 core 去执行;而对于 Solaris 的调度,LWP 可以任意被调度到可用的 kernal thread 上,进而有机会被调度到其他 CPU core 上( https://www.usenix.org/legacy/publications/library/proceedings/sa92/eykholt.pdf )。这些历史背景才是当时选择的原因。当然,Linux 现在对实时性的支持已经非常好了,所以 Project Loom 重拾历史积淀,再次把用户侧线程引入 Java 。
lxdlam
2021-12-17 14:10:55 +08:00
@ipwx stackful 和 stackless 最大的区别不是保存调用栈的问题,是可不可以在任意函数中启动异步调用的问题,这个问题最经典的例子就是 js 所谓的有色函数,或者说 async/await 的传染问题。考虑一个非常经典的例子:

```
async function g() { return 123; }

function f(n) {
let x = (await g()) + n;
return x;
}
```

假定这个函数是可以运行的,那就有一个问题:`g` 要被调度走,肯定有其他函数要切入运行,栈要被更改,那当 `g` 执行完的时候,`x` 和 `n` 咋办?我们有两种思路:
1. 调用 `g` 的时候,我们记一下调用栈,这下 `f`, `x` 和 `n` 我都记下来了;
2. 让 `f` 也变成 `g` 一样的东西,这样 `f` 自己就会记录 `x` 和 `n` 的关系。

正好就是有栈和无栈协程的区别。

Project Loom 也打算把 stack 存下来放在 Java Heap 里面,搞成 stackful 的。至于 Fibers 为啥挂了,是因为 Fibers 把一个指挥内核调度的可能性给了用户,误用的可能性更高,比如可能跟系统的带锁操作形成死锁( fiber 在等一个 syscall 之后 yield ,但是系统在等 fiber 释放执行这个 syscall 的资源),也是一种优先级反转的特例。种种情况下,Fibers 虽然 API 没有被干掉,但是用户不咋用了。
ipwx
2021-12-17 14:34:19 +08:00
@lxdlam 在我看来 f(n) 就应该也是 async 的,虽然对于这方面 js 我不太熟。

我的技术背景是 C++、Python 比较熟,曾经用 Scala 写过 Future / Promise 的程序不过已经很久没有用了。C++ 自己写过 Actor framework ,Python 曾经用过 tornado 和 gevent ,现在挺喜欢用 async / await 。

因为我的这些背景,所以我觉得程序员非常准确明白所有这些并发方法(线程池、event loop 、callback 、future promise 、async await )是基本功,而且不同任务可能偏重于不同的技术。当一个任务需要特别准确把握延迟的时候,随意上下文切换是不可接受的,那么就得手撸 event loop ,顶多在 event loop 上自己封装一下比如 actor model 或者 future / promise 呗。

所以我比较 anti go 语言这种类似于线程的协程吧,总感觉它管得太多了。
statumer
2021-12-17 14:35:25 +08:00
@ipwx C# 的 Async, Await 的问题是, C# 无法要求所有库开发者都用异步网络编程, 如果你的调用栈是异步调用同步, 同步再调用异步, 异步 -> 同步 -> 异步, 线程还是被同步 syscall 阻塞. 在这个方面, C++则更加糟糕, 一部分类库(比如 brpc) 甚至会自己实现协程和协程调度(一个上下文切换保存 xmm 的协程,呵呵[1]), 而不是被一个统一的调度器管理. 写 C++ Server 的时候,redis/mysql/grpc/http 如果网络框架不统一可真是折磨,逼大家写 port 。

Go 在实现方面完全有能力做到像你说的那样, 只在 syscall 的时候切换上下文, 但是考虑这样一个问题,如果其他 goroutine 在等待锁,等待其他 goroutine 的消息,而你的计算任务又没那么重要,你怎么让出控制流?最理想的方式肯定是让开发者自己去让渡控制权,超细粒度控制,但是这样做无疑给开发者造成了额外的负担(比 if err!= nil 还严重),所以搞成了像现在这样的编译器自动插入 hook 。

[1]: https://github.com/apache/incubator-brpc/blob/master/src/bthread/context.cpp
ipwx
2021-12-17 14:43:00 +08:00
@statumer C# 那种可以通过一个线程池(专门排队做阻塞任务) + 非阻塞的 IO 。不过确实,非阻塞 IO 的库开发需要时间。所以这就造成了 Go 语言在网络微服务等领域特别被人追捧,因为在这里面编译期自动插入 hook 确实省事得多,一下子就可以适配所有阻塞的库。

这其实就是工程性的妥协了。C++ 和 C# 没有编译器的 hack ,导致大家不得不开发真正的非阻塞库。但是反过来,这样就倒逼 C++ C# 这种语言用户开发出真正高性能的非阻塞库了。然而毕竟真正需要高性能非阻塞的库不多,Network IO, MySQL / PostgreSQL / MongoDB ,加上一定的 File IO 和基础框架,就解决了。

其他的并不需要处理这种事情。应用逻辑方面需要解决“锁”的阻塞问题,其实“不阻塞”才是更正确的方案。逻辑复杂的应用从多线程并发阻塞模型换成 Actor model ,你会发现写起来就是第二次工业革命和第三次工业革命的区别。

Go 语言编译器允许大家偷懒,大家自然没有动力去精益求精,“够用就行”。反而抑制了更精巧的程序 —— 当然这也是做 “工作” 和做 “艺术品” 态度的差别吧 hhh
lemonf233
2021-12-17 14:52:20 +08:00
楼楼有空要不附言总结下?楼里面各种主张都有...太迷惑了
lxdlam
2021-12-17 15:08:25 +08:00
@ipwx 其实“非常准确明白这些并发方法”本身是一种提升了异步编程门槛的行为,这一串概念能引出来七八个名词和一大堆文章。我个人是非常乐意看见 goroutine 出现的,虽然确实是 dirty and hack 的,但是它足够简单,确确实实解决了实际问题(虽然我还是认为~~go 是垃圾~~)。

对于语言来说,提供一个尽可能统一的抽象,并让不同的 runtime 去实现不同的做法,再实现不同的生态,是一个非常好的做法,也是 Rust 社区正在采纳的做法。这确实会导致出现“事实标准”这一情况(比如 tokio 现在的绝对领导地位),但是给了用户在 consistent 的 interface 下,可以自主根据 workload 切换运行方式的自由。

实际上,阻塞和不阻塞不是异步问题的本源,归根到底还是我们希望每个任务都能够拿到足够多的时间片去跑,而不是要么被无用的 task 频繁占用 CPU ,要么在等一些其实没有意义事情而导致时间流失。从这两个角度出发,一方面我们尝试通过各种方式来更巧妙地让 task 在正确的时候和地方执行,而另一方面我们也在尽力去把各种有这种所谓“无用等待”的地方做成 pubsub 的事件行为把等待让出来,给其他 task 时间。说来也比较奇妙,CPU 和操作系统设计的时候已经采纳了基于终端的事件通知机制,但是应用层全面用到这个特性还是过去了很久。

至于 Worse is better 还是 Do the right thing ,我们都可以另起一次讨论了 :)。
lxdlam
2021-12-17 15:11:06 +08:00
@lxdlam 基于终端 -> 基于中断
ipwx
2021-12-17 15:37:02 +08:00
@lxdlam 你说的很对,所以虽然我个人不喜欢 2333 ,虽然 Go 是一种工程妥协( Go 到处是工程妥协,比如现在还没完全上线的泛型),但是它毫无疑问是有用的。可以说 Go 语言这种编译器抽象对于大部分 Web 应用都是有效的,这本就是它的专长。

无效的领域,其实倒也不少。比如追求精确控制延迟的 real-time system 、高频交易之类的低延迟系统;比如本来就需要精确控制所有开销的游戏引擎、OS 内核、数据库系统。比如各种传统计算机算法。比如科学计算…… 但说实话这些本来就不是 Go 的专长。

我对于本楼有些微词的地方是,明明有这么多不同的场景,很多 Go 语言拥蹙就只知道互联网业务这一亩三分地,以为 Go 的协程就是圣经。。。互联网程序员现在网上的声音太大了,以至于 “技术” 就只有互联网并发 hhh
janus77
2021-12-17 18:48:00 +08:00
我也不是很清楚,看了一些文章,总的来说就是 2 点:1 更轻量,开销更小; 2 写起来语法爽,开发成本更低。
coldear
2021-12-18 01:35:38 +08:00
@ipwx kotlin coroutine 是用 callback 实现的
kingofzihua
2021-12-20 13:23:08 +08:00
@lemonf233 兄弟,你太看的起我了,我要是真的懂,我就不会问了,,你把所有评论全看一遍,就发现不只是简单几句话能说明白的,还是看看大家说的,然后自己慢慢理解吧,我也在一点点的尝试看懂他们说的,目前能力还没达到能做总结 QAQ
hongweiliuruige
2022-01-04 17:17:52 +08:00
@ipwx nodejs 的协程不是 stackless 的吗,,那岂不是很完美,,大多数生态也都支持 async await ,即使不支持,通过 promise 包裹一下也都支持了。。。
hongweiliuruige
2022-01-04 17:38:48 +08:00
@lxdlam 那么 stackless 和 stackful 有什么优劣呢
gy123
2022-09-22 17:14:57 +08:00
例如一个基于 epoll 的 nio 的非阻塞服务 ServerSocketChannel,此时来了 10w 请求,如果需要同时并发处理:
1.使用线程:
(1)采用 reactor 模式,处理操作连接的线程一个就够了;然后就是分发读写就绪的连接,进行处理;
(2)socketchannel 由于设置为了非阻塞的,所以读写每次都是直接返回,所以需要轮询监听,此时不会阻塞线程;那么就需要 10w 的线程同时处理,由于操作系统抢占式的调度线程,10w 线程来回切换,资源消耗极大; (当然此时可以使用到线程池,使用后的效果是,并发执行最大线程池数量的任务,然后依次到队列取后续任务执行)
2.使用协程:
(1)依然采用 reactor 模式,处理操作连接的线程一个就够了;然后就是分发读写就绪的连接,进行处理;
(2)直接创建 10w 个协程,处理连接的读写;此时为用户态切换执行,资源消耗很小;(此时用到的线程数是很少的,并且执行效果是 10w 个并发执行)

所以看出来 协程是更优的线程池实现处理任务的方式;

不知道说的对不对,欢迎指正~

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/821871

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX