go 多线程访问 curl_Go 协程简介

背景

进程/线程切换:

​ 计算机系统一定要和外接设备打交道,IO是低速的,而cpu是高速的,就会产生cpu等待io的情况。为了充分利用计算资源,现代操作系统引入了线程和进程的概念。当进程或者线程时间片到期、遇到阻塞事件、加锁阻塞时,os会保存上下文并挑选新的线程或者进程切换执行。

​ 进程或线程切换需要完成一系列的操作,百度的经验表明,计算机完成一次线程切换耗时大概在2-4微秒,同时切换页表寄存器后会导致TLB失效,也会造成1微秒的时间成本;相比之下,对于一个简单的echo程序,cpu的处理时间在200-300ns,远远低于进程和线程的切换时间;在处理大并发的服务器中,进程和线程的切换成本是不可忽略的。由此引发了C10K问题,利用多线程处理的并发数到达10K级别时,cpu耗时主要集中在线程切换。

IO多路复用与Reactor模式

​ 为了减少服务器在进程/线程切换过程中的耗时,IO多路复用技术被提出。典型的IO多路复用技术有 linux平台的epoll、Unix的kqueue、Windows下的ioctl。IO多路复用技术允许应用程序对多个网络连接进行同时监听,判断能否进行读写操作。

​ 当IO并发度很低的时候,IO多路复用并不比多线程技术高效,但当IO并发度提高后,线程切换成本会大幅度提高,cpu cache无法得到充分利用;而IO多路复用可以避免线程之间的切换,提升系统的处理能力。

​ 为了利用IO多路复用技术,状态机模式在服务器程序中得到了广泛应用。在大并发下,使用状态机模式可以获得较高的性能。典型的比如Java中的Netty,程序员的业务类需要实现接口,处理每次读入的数据。这种编程方式对程序员要求较高,相比于多线程编程降低了开发效率。

协程

​ 不同的时期,协程的定义和名称都不相同。协程最重要的两个特征是:协作和过程。协作的意思是协程的调度是非抢占式的,需要主动切换到下一个执行的协程。当前,比较被广泛接受协程定义是协程包含运行时的寄存器以及函数调用栈。

当协程执行的时候遇到IO事件的时候,如果IO未准备好,程序会保存协程的上下文(寄存器的数值、函数栈的起始位置),并维护IO事件和协程的绑定关系;之后, 程序会检查是否存在准备好的IO事件,如果有挑选IO事件对应的协程,切换上下文,继续执行对应的协程。

​ 与线程和进程的切换相比,协程的切换仅仅会修改CPU寄存器的值,切换开销可以忽略不计。

​ 在工业界比较出名的协程库有腾讯的colib、百度的brpc中的BTHREAD。

Goroutine

简介

​ Go在设计之初就是为了支持高并发。Go在语言层次上支持协程。Goroutine是协程在go语言中的实现,不同于C++中的第三方协程库,Go在runtime中对协程进行了封装和处理。

​ Go语言对程序员屏蔽了线程的存在,用户不需要手动操作配置线程或者进程,就可以最大化的利用cpu的并发能力。尽管Go语言提供了os 包,可以创建进程,调用os平台的系统调用。大多数情况下,使用Go单进程加多协程的模式就可以满足业务需求。

Runtime

​ runtime是Go语言的基础设施,作用类似于jvm。runtime完成了:

  • 特化语法的实现;map、channel、slice、string等内置类型的实现;
  • 协程的执行调度、内存分配、GC;
  • OS和CPU操作的封装;
  • pprof、trace、race检测的支持;

runtime相关功能以链接库的形式提供,并在执行到相关事件时触发。

特化语法以及内置类型

​ 每一种编程语言都有自己特化的语法,这些语法编程语言是无法自举实现的。比如Java中的String,用户自己实现的String,无法做到与Java其余部分协调。Go语言也存在着特化的语法,典型的特化语法如下,Go会将他们替换成runtime下的函数;

631f3c239a58647b4bc25f37f66e7892.png

协程的执行调度、内存分配、GC

​ Go语言将这些功能,封装在runtime提供的函数中。当执行特定操作时会触发相关的事件。

协程、Processor与Machine

​ Go设计的目的之一是充分利用CPU的多核运算能力,Go的进程在运行时会启动多个线程。

​ 早期版本的Go语言中,用户可以通过环境变量指定Go程序需要的线程数目,运行过程中runtime创建对应的线程并将协程分配给线程执行。在实际中,这个模式存在问题:比如在Linux中,磁盘IO会阻塞执行的线程,从而降低Go程序里的实际并发数目;然而令人沮丧的是,Unix设计的时候认为磁盘IO是同步的,无法通过IO多路复用加非阻塞IO的方式避免线程阻塞。

Unix中,磁盘IO不支持非阻塞的模式,既有硬件上的原因也有系统设计上原因。硬件上,传统的磁盘设计没有提供轮询传输的接口,而网络设备设计之初硬件上就支持异步IO;同时硬盘在现代计算机的位置十分重要,现阶段内存是易失性,一旦断电 ,内存中的数据就会全部清空;美好进行磁盘操作的过程中 进程是不被停止的。

​ 当前的Go中,为了充分利用cpu的并发能力,也为了减少线程数目较多造成的切换成本,runtime会努力保证处于运行状态的线程数目不变。环境变量GOMAXPROCS决定了Go程序中处于运行状态的线程数目(缺省为CPU的核数)。Go的runtime维护了一个线程池,当一个线程从运行变为阻塞的时候,runtime会从线程池中获取一个线程,变为运行状态;当阻塞的线程变为非阻塞状态,runtime会将其阻塞并放入线程池中,通过这个机制,runtime保证Go程序中实际运行的并发数目不变。

​ 为了支持这种设计,runtime引入了Processor概念,一个Processor表示一个逻辑的处理器; 在运行中,runtime会为Processor绑定一个Machine(线程),Processor通过绑定的Machine执行Goroutine。

​ Processor数目代表了实际并发执行的非阻塞的线程(Machine)数目,runtime会创建GOMAXPROCS个Processor,每个Processor绑定一个线程。当协程执行阻塞操作的时候,runtime会绑定一个新的线程给Processor上,同时将对应的协程状态改为阻塞态;当阻塞的线程的操作完成后,runtime会将该线程放回线程池,同时将对应协程状态改为可运行放入等待队列,将来分给新的Processor执行。

下面是一个例子,P绑定了线程M0正在执行协程G0,当遇到阻塞事件的时候,runtime会为P绑定一个新的线程M1,执行新的新的线程。M0和G0由runtime维护。

7bbc065e66ad8269b8c7aca4cf2f3efb.png
图片非原创

Goroutine的组织

​ Go支持大并发的设计,只要内存足够,就可以不断创建协程。runtime负责管理和调度协程。

​ Go的runtime维护了一个全局的Global Run Queue(GRQ),采用链表的设计,可以容纳足够多的协程。每个Processor也有一个Local run queue(LRQ),最多可以容纳256个等待执行协程,同时Processor会持有一个runnext协程,表示这个Processor最新创建的协程。一个Processor可以持有257个等待执行的协程。这种设计是出于局部性的考虑。

​ Processor的代码片段:

    runqhead uint32
    runqtail uint32
    runq     [256]guintptr
    runnext guintptr

​ 当执行go func的时候,runtime会新建一个协程,将runnext设置为新协程;如果runnext原来不为空,则将原值放入LRQ。由于LRQ的容量有限,当LRQ满了,runtime会将一半的协程放入GRQ中。

​ 现代CPU大部分采用了NUMA架构,CPU的每个核心都有自己对应缓存和对应的内存区域;CPU核心只能访问自己的缓存,如果访问其余CPU对应的内存区域,会发生锁总线和同步数据,降低了cpu处理效率。基于此,大部分框架中,每个线程都有自己对应的任务队列,任务优先在创建自己的线程上执行。Processor的LRQ就是这种设计,当一个协程产生新的协程的时候,runtime会放入该协程对应的Processor中。

50b2db2e7c70c28bdb35beb7fa0fe677.png
图片非原创,GMP与LRQ GRQ示意图

runqput

​ 程序调用go func后,runtime会调用runqput,将协程交给对应的Processor。

1. 第一步runtime获取P上runnext的旧值记录为oldnext,并将runnext设置为该协程;
2. 如果LRQ还有空间,runtime会将oldnext追加到LRQ的尾部;
3. 如果LRQ满了,则先将oldnext插入GRQ,然后将LRQ的前128个协程放入GRQ。

findrunnable

​ 当需要切换协程的时候,runtime会调用findrunnable决定Processor下一个需要执行的协程。操作如下:

  1. P每切换61次协程,会尝试从GRQ获取协程执行,GRQ为空或者切换次数不是61的倍数执行下一步;
  2. 判断runnext是否为空,不为空切换到该协程,并将runnext设置为空;runnext为空执行下一步
  3. 从LRQ队首获取协程切换;LRQ为空执行下一步;
  4. 从GRQ队首开始获取特定数目的协程,获取的协程数目由以下代码计算:
//sched.runqsize GRQ的协程数目
//gomaxprocs P的数目
n := sched.runqsize/gomaxprocs + 1
if n > sched.runqsize {
   n = sched.runqsize
}
if max > 0 && n > max {
   n = max
}
if n > int32(len(_p_.runq))/2 {
   n = int32(len(_p_.runq)) / 2  //128
}

​ 如果获取成功则第一个执行,并将余下的协程放入P的LRQ;如果获取数目为0表示GRQ也为空;

5. 检查网络阻塞的netpoller,获取IO准备好的协程列表,使用第一个切换,并将剩余的协程放入GRQ尾部;

6. 如果 1-5都没有获取协程,那么从其余的P上获取一半协程执行。

协程切换时机

​ 当发生某些事件的时候,runtime会调用findrunnable 函数,挑选下个协程切换执行。

72615f1cc28bd6093a25dcee17e772db.png

​ 注意:Go1.14之前协程是非抢占式调度,如果一个协程一直在执行计算操作,那么这个协程永远不会结束,该Processor的其余协程永远得不到调度。某DB的计算层使用Go语言编写一直存在调度问题。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值