Go语言从诞生到普及已经三年了,先行者大都是Web开发的背景,也有了一些普及型的书籍,可系统开发背景的人在学习这些书籍的时候,总有语焉不详的感觉,网上也有若干流传甚广的文章,可其中或多或少总有些与事实不符的技术描述。希望这篇文章能为比较缺少系统编程背景的Web开发人员介绍一下goroutine背后的系统知识。
1.操作系统与运行库2.并发与并行(ConcurrencyandParallelism)3.线程的调度4.并发编程框架5.goroutine
1.操作系统与运行库
对于普通的电脑用户来说,能理解应用程序是运行在操作系统之上就足够了,可对于开发者,我们还需要了解我们写的程序是如何在操作系统之上运行起来的,操作系统如何为应用程序提供服务,这样我们才能分清楚哪些服务是操作系统提供的,而哪些服务是由我们所使用的语言的运行库提供的。
除了内存管理、文件管理、进程管理、外设管理等等内部模块以外,操作系统还提供了许多外部接口供应用程序使用,这些接口就是所谓的“系统调用”。从DOS时代开始,系统调用就是通过软中断的形式来提供,也就是 的INT21,程序把需要调用的功能编号放入AH寄存器,把参数放入其他指定的寄存器,然后调用INT21,中断返回后,程序从指定的寄存器(通常是AL)里取得返回值。这样的做法一直到奔腾2也就是P6出来之前都没有变,譬如windows通过INT2E提供系统调用,Linux则是INT80,只不过后来的寄存器比以前大一些,而且可能再多一层跳转表查询。后来,Intel和AMD分别提供了效率更高的SYSENTER/SYSEXIT和SYSCALL/SYSRET指令来代替之前的中断方式,略过了耗时的特权级别检查以及寄存器压栈出栈的操作,直接完成从RING3代码段到RING0代码段的转换。
系统调用都提供什么功能呢?用操作系统的名字加上对应的中断编号到谷歌上一查就可以得到完整的列表(Windows,Linux),这个列表就是操作系统和应用程序之间沟通的协议,如果需要超出此协议的功能,我们就只能在自己的代码里去实现,譬如,对于内存管理,操作系统只提供进程级别的内存段的管理,譬如Windows的virtualmemory系列,或是Linux的brk,操作系统不会去在乎应用程序如何为新建对象分配内存,或是如何做垃圾回收,这些都需要应用程序自己去实现。如果超出此协议的功能无法自己实现,那我们就说该操作系统不支持该功能,举个例子,Linux在2.6之前是不支持多线程的,无论如何在程序里模拟,我们都无法做出多个可以同时运行的并符合POSIX.1c语义标准的调度单元。
可是,我们写程序并不需要去调用中断或是SYSCALL指令,这是因为操作系统提供了一层封装,在Windows上,它是NTDLL.DLL,也就是常说的NativeAPI,我们不但不需要去直接调用INT2E或SYSCALL,准确的说,我们不能直接去调用INT2E或SYSCALL,因为Windows并没有公开其调用规范,直接使用INT2E或SYSCALL无法保证未来的兼容性。在Linux上则没有这个问题,系统调用的列表都是公开的,而且Linus非常看重兼容性,不会去做任何更改,glibc里甚至专门提供了syscall(2)来方便用户直接用编号调用,不过,为了解决glibc和内核之间不同版本兼容性带来的麻烦,以及为了提高某些调用的效率(譬如__NR_gettimeofday),Linux上还是对部分系统调用做了一层封装,就是VDSO(早期叫linux-gate.so)。
可是,我们写程序也很少直接调用NTDLL或者VDSO,而是通过更上一层的封装,这一层处理了参数准备和返回值格式转换、以及出错处理和错误代码转换,这就是我们所使用语言的运行库,对于C语言,Linux上是glibc,Windows上是kernel32(或调用msvcrt),对于其他语言,譬如Java,则是JRE,这些“其他语言”的运行库通常最终还是调用glibc或kernel32。
“运行库”这个词其实不止包括用于和编译后的目标执行程序进行链接的库文件,也包括了脚本语言或字节码解释型语言的运行环境,譬如Python,C#的CLR,Java的JRE。
对系统调用的封装只是运行库的很小一部分功能,运行库通常还提供了诸如字符串处理、数学计算、常用数据结构容器等等不需要操作系统支持的功能,同时,运行库也会对操作系统支持的功能提供更易用更高级的封装,譬如带缓存和格式的IO、线程池。
所以,在我们说“某某语言新增了某某功能”的时候,通常是这么几种可能:1.支持新的语义或语法,从而便于我们描述和解决问题。譬如Java的泛型、Annotation、lambda表达式。2.提供了新的工具或类库,减少了我们开发的代码量。譬如Python2.7的argparse3.对系统调用有了更良好更全面的封装,使我们可以做到以前在这个语言环境里做不到或很难做到的事情。譬如JavaNIO
但任何一门语言,包括其运行库和运行环境,都不可能创造出操作系统不支持的功能,Go语言也是这样,不管它的特性描述看起来多么炫丽,那必然都是其他语言也可以做到的,只不过Go提供了更方便更清晰的语义和支持,提高了开发的效率。
2.并发与并行(ConcurrencyandParallelism)
并发是指程序的逻辑结构。非并发的程序就是一根竹竿捅到底,只有一个逻辑控制流,也就是顺序执行的(Sequential)程序,在任何时刻,程序只会处在这个逻辑控制流的某个位置。而如果某个程序有多个独立的逻辑控制流,也就是可以同时处理(deal)多件事情,我们就说这个程序是并发的。这里的“同时”,并不一定要是真正在时钟的某一时刻(那是运行状态而不是逻辑结构),而是指:如果把这些逻辑控制流画成时序流程图,它们在时间线上是可以重叠的。
并行是指程序的运行状态。如果一个程序在某一时刻被多个CPU流水线同时进行处理,那么我们就说这个程序是以并行的形式在运行。(严格意义上讲,我们不能说某程序是“并行”的,因为“并行”不是描述程序本身,而是描述程序的运行状态,但这篇小文里就不那么咬文嚼字,以下说到“并行”的时候,就是指代“以并行的形式运行”)显然,并行一定是需要硬件支持的。
而且不难理解:
1.并发是并行的必要条件,如果一个程序本身就不是并发的,也就是只有一个逻辑控制流,那么我们不可能让其被并行处理。
2.并发不是并行的充分条件,一个并发的程序,如果只被一个CPU流水线进行处理(通过分时),那么它就不是并行的。
3.并发只是更符合现实问题本质的表达方式,并发的最初目的是简化代码逻辑,而不是使程序运行的更快;
这几段略微抽象,我们可以用一个最简单的例子来把这些概念实例化:用C语言写一个最简单的HelloWorld,它就是非并发的,如果我们建立多个线程,每个线程里打印一个HelloWorld,那么这个程序就是并发的,如果这个程序运行在老式的单核CPU上,那么这个并发程序还不是并行的,如果我们用多核多CPU且支持多任务的操作系统来运行它,那么这个并发程序就是并行的。
还有一个略微复杂的例子,更能说明并发不一定可以并行,而且并发不是为了效率,就是Go语言例子里计算素数的sieve.go。我们从小到大针对每一个因子启动一个代码片段,如果当前验证的数能被当前因子除尽,则该数不是素数,如果不能,则把该数发送给下一个因子的代码片段,直到 一个因子也无法除尽,则该数为素数,我们再启动一个它的代码片段,用于验证更大的数字。这是符合我们计算素数的逻辑的,而且每个因子的代码处理片段都是相同的,所以程序非常的简洁,但它无法被并行,因为每个片段都依赖于前一个片段的处理结果和输出。
并发可以通过以下方式做到:
1.显式地定义并触发多个代码片段,也就是逻辑控制流,由应用程序或操作系统对它们进行调度。它们可以是独立无关的,也可以是相互依赖需要交互的,譬如上面提到的素数计算,其实它也是个经典的生产者和消费者的问题:两个逻辑控制流A和B,A产生输出,当有了输出后,B取得A的输出进行处理。线程只是实现并发的其中一个手段,除此之外,运行库或是应用程序本身也有多种手段来实现并发,这是下节的主要内容。
2.隐式地放置多个代码片段,在系统事件发生时触发执行相应的代码片段,也就是事件驱动的方式,譬如某个端口或管道接收到了数据(多路IO的情况下),再譬如进程接收到了某个信号(signal)。
并行可以在四个层面上做到:
1.多台机器。自然我们就有了多个CPU流水线,譬如Hadoop集群里的MapReduce任务。
2.多CPU。不管是真的多颗CPU还是多核还是超线程,总之我们有了多个CPU流水线。
3.单CPU核里的ILP(Instruction-levelparallelism),指令级并行。通过复杂的制造工艺和对指令的解析以及分支预测和乱序执行,现在的CPU可以在单个时钟周期内执行多条指令,从而,即使是非并发的程序,也可能是以并行的形式执行。
4.单指令多数据(Singleinstruction,multipledata.SIMD),为了多媒体数据的处理,现在的CPU的指令集支持单条指令对多条数据进行操作。
其中,1牵涉到分布式处理,包括数据的分布和任务的同步等等,而且是基于网络的。3和4通常是编译器和CPU的开发人员需要考虑的。这里我们说的并行主要针对第2种:单台机器内的多核CPU并行。
关于并发与并行的问题,Go语言的作者RobPike专门就此写过一个幻灯片:中医治疗白癜风费用北京治疗白癜风那个医院好