V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
The Go Programming Language
http://golang.org/
Go Playground
Go Projects
Revel Web Framework
q397064399
V2EX  ›  Go 编程语言

golang 的协程比线程轻量级 轻量级在哪里,谢谢

  •  
  •   q397064399 · 2017-05-14 07:53:38 +08:00 · 4087 次点击
    这是一个创建于 2745 天前的主题,其中的信息可能已经有所发展或是发生改变。

    无论是协程还是线程应该都是占用内存用来维护函数调用栈帧,

    如果是同步 IO 系统阻塞调用的话,

    线程无非是切换栈帧跟当前寄存器,

    协程同样是切换栈帧跟当前寄存器,

    40 条回复    2017-05-15 15:26:55 +08:00
    q397064399
        1
    q397064399  
    OP
       2017-05-14 07:56:50 +08:00
    另外一个问题,如果大量的线程调用阻塞 IO 会引起 cpu 大量的空转吗?

    golang 的协程采用的是将阻塞 IO 用 epoll/select 等多路 IO 复用技术包装了一下,
    说白了就是用操作系统注册的硬件中断来判断哪些阻塞式的 IO 是可以返回的,然后切回协程。
    binux
        2
    binux  
       2017-05-14 08:04:34 +08:00
    q397064399
        3
    q397064399  
    OP
       2017-05-14 08:30:07 +08:00
    @binux 找了很多知乎 google 的回答,,看得主要还是懵逼啊,,
    一个代码的线性逻辑流,无非是调用的栈帧跟当前寄存器 在这个上面,线程跟协程应该没有本质区别,
    无非是 golang 的协程,使用了 select epoll 包装了同步 IO,这样在语言层面上可以切换协程,
    而线程通常采用的是阻塞 IO 使用的是系统的调度,两者调度存在的区别是 select/epoll 是多路 IO 复用技术,
    传统的阻塞 IO 是等待系统调用返回
    q397064399
        4
    q397064399  
    OP
       2017-05-14 08:34:13 +08:00
    @binux 我觉得我这个问题,完全不是通过 google 就能解释的清楚的
    zmj1316
        5
    zmj1316  
       2017-05-14 08:50:06 +08:00
    线程是操作系统实现的,所以切换线程时需要保存和重建栈和寄存器的状态
    协程一般是编程语言实现的,“协程同样是切换栈帧跟当前寄存器”这句话是不对的,我的理解是协程是变成语言的语法糖,简化了复杂的状态机,实际上还是在同一个线程和地址空间里面执行的。

    你 google 解决不了估计是操作系统知识忘了吧...
    limhiaoing
        6
    limhiaoing  
       2017-05-14 08:52:29 +08:00 via iPhone
    goroutine 运行时栈初始是 2kb,而线程一般是几 MB,当创建几千个得时候,goroutine 的内存开销远小于线程。
    goroutine 的调度不需要进入内核,也比线程的开销要小。
    q397064399
        7
    q397064399  
    OP
       2017-05-14 08:55:32 +08:00
    @zmj1316 寄存器应该是要切换的,,一个协程对应一个函数,函数里面有局部变量 等计算的临时结果,如果是一个 for 循环,golang 协程要当前这个循环停下来,然后去执行另外一个协程,,肯定要保存,
    q397064399
        8
    q397064399  
    OP
       2017-05-14 08:56:18 +08:00
    @limhiaoing 如果是这样的话,那应该好理解一点,,但是 Linux 使用线程池,或者使用 ulimt 是可以调节初始栈的大小的,
    q397064399
        9
    q397064399  
    OP
       2017-05-14 08:57:04 +08:00
    @zmj1316 栈肯定是要切换的,一个协程对应是一个函数调用的栈帧,,
    q397064399
        10
    q397064399  
    OP
       2017-05-14 08:58:34 +08:00
    @limhiaoing 不用进入内核是一个依据,毕竟从用户态切换到内核态,也是有开销的
    wwqgtxx
        11
    wwqgtxx  
       2017-05-14 09:04:10 +08:00 via iPhone
    @q397064399 个人建议你看看操作系统原理方面的书,一个线程的切换他还是要经过操作系统本身庞大的调度器,然后修改 pcb 块,重排等待队列等等过程
    而协程基本上只是压栈和改寄存器,其他步骤少了很多
    limhiaoing
        12
    limhiaoing  
       2017-05-14 09:04:12 +08:00 via iPhone   ❤️ 1
    goroutine 现在的实现也不是严格的协程了,协程是非抢占的,goroutine 的调度是抢占的。
    q397064399
        13
    q397064399  
    OP
       2017-05-14 09:09:53 +08:00
    @wwqgtxx 多谢,,因为我个人对线程跟协程 理解的还是很浅显的,,
    我对协程的理解是 其调度是语言级别的,无非是在使用阻塞等 IO 的时候 优化一下,,
    让当前协程停下来,去跑其它的协程,当然一些耗时的循环 我不知道 golang 的协程是怎么中断 然后调度其它协程的


    @limhiaoing 调度方法上的 抢占跟非抢占是什么区别?类似加锁的公平锁跟非公平锁么?
    kier
        14
    kier  
       2017-05-14 09:18:54 +08:00
    @q397064399 golang 也是多线程的啊,只不过 1 个线程可能对于多个协程,所以耗时的循环,也就阻塞一个线程,不会导致整个程序阻塞
    zmj1316
        15
    zmj1316  
       2017-05-14 10:03:42 +08:00
    @q397064399 我只知道,C#里面的 enumerator 虽然写的时候是一个函数,其实是作为一个数据结构(类)存在的,看起来函数里面的局部变量是在栈里面存在的,其实是这个类里面的成员,你写 yield 的时候,语言就是给类多加了一个状态。
    这个有很明显的问题就是在 enumerator 的 for 循环里面 capture 循环里面的局部变量和在普通函数里面不同,因为 capture 到的可能实际上是一个成员变量。没写过 go,不知道 go 是怎么实现的...
    zmj1316
        16
    zmj1316  
       2017-05-14 10:22:55 +08:00
    @q397064399 #13

    线程的抢占由操作系统完成,因为操作系统可以在保存完整的栈和寄存器等信息,因此在任何时候都可以抢占正在执行的线程,之后再还原回去,开销大;
    我所理解的协程的调度不会保存完整的栈和寄存器信息,所以只能在预先设定好的位置调度出去(类似存档点?),但是开销小,并且可以由应用程序控制调度;
    wwqgtxx
        17
    wwqgtxx  
       2017-05-14 10:25:23 +08:00 via iPhone   ❤️ 1
    @q397064399 go 的耗时循环的退出机制其实是在编译的时候往里面插代码,执行一段时间就自行退出
    wwqgtxx
        18
    wwqgtxx  
       2017-05-14 10:28:00 +08:00 via iPhone
    @zmj1316 goroutine 的切换代码依然是用 asm 实现的,这个在 golang 的源代码中有,要不然你没办法保存执行到了函数中的第几句,也没法保存过程中的局部变量的值呀
    kindjeff
        19
    kindjeff  
       2017-05-14 10:37:24 +08:00
    相比操作系统线程,协程维护的信息更少;
    协程调度机制更简单;
    协程调度的时候始终在用户态,不用从用户态切换到内核态。
    kindjeff
        20
    kindjeff  
       2017-05-14 10:53:36 +08:00
    我还有一个不知道对不对的类比:
    可以把“函数调用” “回调” “协程”编程模型拿来比较一下。
    函数调用是你在代码里显式的写出来,然后代码运行到这里就会进入调用的函数,设置相关的栈上下文等信息;
    回调的方法是在程序运行过程中知道发生了某个事件,当前顺序执行的代码让出执行权,然后进入回调的函数设置相关上下文;
    而如 Python 的协程是在你显式的写了让出( yield/await )之后,当前正在顺序执行的代码切换到另一个协程的上下文,一个协程和回调函数一样,只是协程切入切除的入口和出口不是唯一的。所以协程非常容易实现,但是操作系统线程还要维护线程的优先级 /线程的缓存,在多核的时候还要考虑负载均衡,切换的时候 /同步原语 /锁的时候还要回到内核态,开销要更大。
    BiuBiuBiuX
        21
    BiuBiuBiuX  
       2017-05-14 11:34:45 +08:00
    @binux 这个是怎么做到的啊
    zonyitoo
        22
    zonyitoo  
       2017-05-14 12:23:16 +08:00   ❤️ 1
    @zmj1316 显然是需要保存栈和寄存器信息,如果是共享栈模式,在每次切换的时候还要把栈的内容复制一份存起来。

    你说的那些保存很少的,是类似于 C#那样的 stackless coroutine,本质是一个状态机,和手写状态机没什么两样,开销与函数调用一致。但显然 Goroutine 不是这样的东西,它是 stackful 的。
    willm
        23
    willm  
       2017-05-14 12:42:01 +08:00 via Android
    线程涉及到用户态和内核态的切换,成本很高;协程是纯用户态实现的,成本很低
    SlipStupig
        24
    SlipStupig  
       2017-05-14 12:42:31 +08:00
    go coroutine 是用的 M:N 的并发模型,M 个线程调度 N 个 coroutine,coroutine 在运行的时候是用户态去切换,而线程切换是内核到用户态通信,然后用户态收到消息后作出响应的操作,所以线程更重一些,而 golang 的 coroutine 的是保存在 TLS 里面当要用的时候进行创建和销毁
    zmj1316
        25
    zmj1316  
       2017-05-14 13:17:43 +08:00
    @zonyitoo 学习了,感觉 go 的方法和 fiber 有点像
    itfanr
        26
    itfanr  
       2017-05-14 14:28:32 +08:00 via Android
    线程切换太重量级 而且各种锁乱飞
    CRVV
        27
    CRVV  
       2017-05-14 17:49:15 +08:00
    @limhiaoing
    “ goroutine 的调度是抢占的”,求这句话的来源

    我看到 goroutine 的调度不是抢占的
    https://github.com/golang/go/issues/10958
    https://github.com/golang/go/issues/11462
    noli
        28
    noli  
       2017-05-14 17:56:03 +08:00   ❤️ 1
    先澄清几个问题:

    1. 什么是协程 ( Coroutine )?

    协程可以主动放弃 CPU 使用权并交给约定的另外一个协程,根据约定方式的差异——明确指定跳转到另一个协程 或者 交还给调用者(另一个协程)——可分为 非对称(两种方式都可以) 和 对称协程(只允许交还 CPU 给调用者) 两种。但这种区分方法并不一定就是业界共识,只是有论文提出过这种概念。

    抛开协程的物理实现方式不谈(即不讨论栈帧和寄存器之类的概念),协程必然存在一个执行上下文的概念。协程切换前后,其执行上下文是不变的,就好像这个切换没有发生过一样。这一点和 线程切换是一样的。

    从这个概念来看,以我所知,goroutine 并不是 coroutine 协程。

    因为实际上程序员并不能自行指定切换到哪一个 goroutine,而是由 gosched 来自行决定下一个要从 suspend 变成 active 的 goroutine。

    但 goroutine 也不能说是抢占式的 (preemptive),因为 goroutine 被切换的时机是明确的,就是访问 chan 等等应该 block 的时候。

    2. 协程的实现方式及代价

    把执行上下文的这个概念,对应到物理实现方式的时候,有很多种实现方式。

    C# yield return 搭配 IEnumeratable 语法糖 和 async await 的实现方式是,在用户代码之中插入状态机代码和数据,使得从程序员的角度看来是保持了上下文不变。这是编译器魔法,是编程语言相关的。

    Windows Fiber API 以及 boost::fiber boost::coroutine 的实现方式是保存寄存器状态和栈帧数据。这实际上就是通用 内核 实现 进程切换的 技术变种(所以实现方式是平台相关的),可以称为 平台魔法。

    这两种魔法跟线程切换的最大区别就是无需系统内核介入( windows fiber 实际上应该不需要深入内核,但是不是真的没有进入内核,我并没有研究)。因此,假设在同一个 OS,同一个 CPU 满负载都用于协程/线程的情况下,支持发生协程切换的最大次数,很大可能是高于线程切换的。

    但是这个数据对实践并没有什么指导意义。因为实际生产环境中很少能把 CPU 合理地用满。

    两种实现方式都需要额外都内存来存储上下文,只不过编译器魔法保存上下文的内存使用概率可能高一点(因为明确知道上下文都数据大小)但是会丧失调用栈上下文的信息,而平台魔法的上下文数据通常是要预先分配(通常会过量分配)。
    binux
        29
    binux  
       2017-05-14 18:12:03 +08:00
    @q397064399 #3
    协程是在一个线程空间内的,至少对于操作系统是这样的。
    你要从寄存器上比较,那就太底层了,不同编译参数对寄存器的使用都是不同的,在这个层面比较没有意义。
    rrfeng
        30
    rrfeng  
       2017-05-14 18:53:00 +08:00
    我的理解
    线程本身是操作系统概念,为了解决进程切换的高代价来实现的。
    后来发现线程切换也不是很完美,那么就有了『用户态线程』以及『协程』,这两个我不是很区分有什么不同,但是确定的是都是在用户态直接切换的,比系统线程轻量,不需要进入内核等。
    而通常意义上的协程例如 lua,切换是手动的或者确定的,你必须用 yield/await 来控制。但是 golang 里,你只需要把 goroutine 扔给 golang 的调度器,它自然帮你干好这些事情。实际上调度器开启了 N 个线程来分配 goroutine,也就是通常说的 M:N,比起手动控制,只简化为一种方式:后台运行,通过 channel 沟通,大大简化了我等程序员的逻辑负担……
    dawniii
        31
    dawniii  
       2017-05-14 19:20:29 +08:00
    @wwqgtxx 原来是这么实现类似抢占式调度的。。。 好像 go 比较早的版本里 如果有一个很大的 for 循环不会自动让出,后来就是这么插代码做的啊
    bigpigeon
        32
    bigpigeon  
       2017-05-14 19:32:40 +08:00
    操作系统是不知道携程的,携程是用户态的线程
    可以这么去理解
    loqixh
        33
    loqixh  
       2017-05-14 19:44:53 +08:00
    @rrfeng c#默认也是 M:N, 但是也可以自己实现 1:N
    ghostheaven
        34
    ghostheaven  
       2017-05-14 22:03:32 +08:00
    @bigpigeon 没用过 go。记得如果是用户态线程的话,不同的线程可能是调度在同一个内核线程上的,他们的内存空间是完全共享的,不知道这样会不会给 go 带来安全隐患。
    cloud107202
        35
    cloud107202  
       2017-05-14 22:15:00 +08:00
    @dawniii 1.2 之后实现的,这种方式弊端是方法不能被内联,否则还是不能让出时间片。
    wwqgtxx
        36
    wwqgtxx  
       2017-05-14 22:34:51 +08:00 via iPhone
    @ghostheaven 并不会呀,系统态线程本来就是内存空间完全共享呀,不共享的只有寄存器状态
    smallHao
        37
    smallHao  
       2017-05-14 22:40:15 +08:00
    如果你想理解他们的区别,那你最好知道它们的实现方式,thread 可以看 pthread,coroutine 的话可以去看看 lambda calculus 里的 CPS
    araraloren
        38
    araraloren  
       2017-05-15 09:15:44 +08:00
    @q397064399
    协程的本质是函数调用的切换,和线程那么重量级的东西不是一回事
    函数切换快不快?所以协程可以达到那么大的并发量
    可以说协程之于线程 其实类似与 线程 之于进程
    woshixiaohao1982
        39
    woshixiaohao1982  
       2017-05-15 11:51:20 +08:00
    @araraloren 即使是 函数调用也有上下文跟栈帧的.. 线程也没多多少东西
    VYSE
        40
    VYSE  
       2017-05-15 15:26:55 +08:00
    Go 里有两种调度, goroutine 和系统 thread 的.
    runtime.GOMAXPROCS(1)下所有 goroutine 在一个 thread 下根据类似 greenlet 方法进行调度,在某些 call 里会 yield 才会切换 cpu 资源给下一个 goroutine.
    但 runtime.GOMAXPROCS(n)下不同 goroutine 会跑在不同 thread 下,就存在同一个时间多个 goroutine 同时运行,这时你就得按传统多任务编程的方法去写代码,不然 crash 事小,数据紊乱事大
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2934 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 33ms · UTC 14:56 · PVG 22:56 · LAX 06:56 · JFK 09:56
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.