Category: 技术专栏

10 Posts

thumbnail
TCP应用编程入门
目录 1. 网络模型 2. 数据链路层 3. 应用场景 4. golang网络模型 5. tcp应用样例 6. 异常场景 7. 参考资料 1、网络模型 1.1 OSI模型 1.2 http报文格式 关键字段:URL 1.3 tcp报文格式 关键字段:端口号 1.4 ip报文格式 关键字段:源IP地址、目的IP地址 1.5 以太帧 关键字段:源MAC地址、目的MAC地址 1.6 问题 lvs、slb的基于端口转发工作在哪一层呢? nginx的域名反向代理转发工作在哪一层呢? 家用普通路由器和无路由功能的交换机呢? 2、数据链路层 通过电信号当中的某一个字段或者光纤当中的某一个固定频率的波来传递控制报文,组合成电物理拓扑和光物理拓扑图; 某一个线路中断时,通过控制报文触发告警,让中断线路的两节点用户数据走其他线路来实现用户网络不中断的目的; 智能化的全自动交换的光网络(ASON); 3、应用场景 Http应用通常用于无状态一次性请求,请求之后连接就关闭的短连接,开发简单; Tcp应用通常用于长连接,客户端和服务端反复发送接收数据,需要自己定义报文格式,开发稍复杂; 4、实现原理 文件路径都在/proc/sys/net目录下 1. TCP收发缓冲区 # TCP接收缓冲(用于TCP接收滑动窗口)的最小值、默认值、最大值 cat /proc/sys/net/ipv4/tcp_rmem 4096 87380 6291456 # TCP发送缓冲(用于TCP发送滑动窗口)的最小值、默认值、最大值 cat /proc/sys/net/ipv4/tcp_wmem 4096 16384 4194304 每个TCP套接字有一个发送和一个接收缓冲区,当某个应用进程调用write时,内核从该应用进程的缓冲区复制发送缓冲区;同样的调用read时,内核从接收缓冲区复制到应用进程的缓冲区。 2. 内核缓冲区 网卡接收到的数据存放到内核缓冲区内,然后内核缓冲区存放的数据根据TCP信息将数据移动到具体的某一个TCP连接上的接收缓冲区内,也就是接收滑动窗口内,整个完成从IP层到传输层的数据传递。 3. 滑动窗口 发送窗口 接收窗口 5、golang网络模型 常见网络模型比较 golang用户态模型 绿色goroutine是接受TCP链接 当完成握手accept返回conn对象后,使用一个单独的goroutine来阻塞读(紫色),使用一个单独的goroutine来阻塞写(红色) 读到的数据通过解码后放入读channel,并由蓝色的goroutine来处理 需要写数据时,蓝色的goroutine将数据写入写channel,从而触发红色的goroutine编码并写入conn 汇总: Golang实利用了OS提供的非阻塞IO访问模式,并配合epoll/kqueue等IO事件监控机制;为了弥合OS的异步机制与Golang接口的差异,而在runtime上做的一层封装,这层封装就是netpoller。 最终的效果就是用户层阻塞,底层非阻塞。 5、tcp应用样例 func HandleConn(conn net.Conn) { defer conn.Close() for { // 从连接里面循环读固定长度的数据,这里一般用封装好的io.ReadFull()接口 bytesRead, err = conn.Read(buffer) totalBytesRead += bytesRead for totalBytesRead < toRead && err == nil { bytesRead, err = conn.Read(buffer[totalBytesRead:]) totalBytesRead += bytesRead } // 向连接里循环写入指定长度的数据,实际conn.Write已经实现成循环去写,不需要应用层循环写入 for totalBytesWritten < toWriteLen && writeError == nil { bytesWritten, writeError = conn.Write(buffer[totalBytesWritten:]) totalBytesWritten += bytesWritten } } } func main() { listen, err := net.Listen("tcp", ":8888") if err != nil { fmt.Println("listen error: ", err) return } for { conn, err := listen.Accept() if err != nil { fmt.Println("accept error: ", err) break } // start a new goroutine…
thumbnail
golang 内存占用信息解读
触发方法: 其中debug参数为2是以高可读的方式提供各活跃Goroutine的状态信息和调用栈信息,更便于理解。 方法一(内容比较少场景适用) curl http://localhost:8001/debug/pprof/heap?debug=2 # 这个输出信息可以参考下面的解读 方法二(内容比较多场景适用) # adserver是我的程序名字,需要换成你自己的 scp 10.200.95.17:/opt/zyz/services/bin/adserver ./ # 参数信息见下面“交互式命令” go tool pprof adserver [-alloc_space|-inuse_space] http://10.200.95.17:8001/debug/pprof/heap?debug=2 # 接下来查看方法参考cpu性能统计方式,是类似的 https://www.vvniu.com/108.html 交互式命令 除了和查看cpu一致的top、list等命令,还有就是查看方式不同。 在使用 pprof 查看内存的时候,通常会用到两个选项。 - alloc_space用于告诉你已经分配了多少内存 - inuse_space用于获得正在使用的内存的数量 信息解读 解读一 # 看第一行的1: 5816320 [1: 5816320],四个数字的含义是当前存活对象的数量、存活对象已经占用的内存、分配的总的数量和所有分配已经占用的内存。 对应于具体代码来讲ip_helper.go:52中对应ipRecords对象,当前占用了582k内存,当前的存活的对象也只有一个,总共分配的对象数量也是1个,总共占用内存也是582k。 1: 5816320 [1: 5816320] @ 0x443d21 0x46806d 0x468614 0x94cf80 0x78a4b7 0x50f457 0x50a82b 0x466017 0x4019cb 0x42fa2f 0x460f01 # 0x46806d adserver/util.init.2+0xaed /home/jiyalei/serving_clean/backend/src/adserver/util/ip_helper.go:52 # 0x468614 adserver/util.init+0xf4 /home/jiyalei/serving_clean/backend/src/adserver/util/ip_helper.go:88 # 0x94cf80 adserver/nctrserver.init+0x40 /home/jiyalei/serving_clean/backend/src/adserver/nctrserver/nctrclient.go:42 # 0x78a4b7 adserver/bidserver.init+0x47 /home/jiyalei/serving_clean/backend/src/adserver/bidserver/spec.go:97 # 0x50f457 adserver/bucket.init+0x47 /home/jiyalei/serving_clean/backend/src/adserver/bucket/util.go:52 # 0x50a82b adserver/antispam.init+0x3b /home/jiyalei/serving_clean/backend/src/adserver/antispam/antispam.go:201 # 0x466017 adserver/server.init+0x47 /home/jiyalei/serving_clean/backend/src/adserver/server/adserver_handler.go:356 # 0x4019cb main.init+0x3b /home/jiyalei/serving_clean/backend/src/adserver/adserver.go:36 # 0x42fa2f runtime.main+0x27f /data/opt/go/src/runtime/proc.go:177 解读二 # ip_helper.go:29对应的对象是`line := strings.TrimSuffix(sc.Text(), ";")`读取文件临时生成的内容[]string,其中当前存活了11个,占用内从352字节,总共分配了14个,总共占用内存是448字节。 11: 352 [14: 448] @ 0x4481c7 0x44804f 0x46787c 0x468614 0x94cf80 0x78a4b7 0x50f457 0x50a82b 0x466017 0x4019cb 0x42fa2f 0x460f01 # 0x46787c adserver/util.init.2+0x2fc /home/jiyalei/serving_clean/backend/src/adserver/util/ip_helper.go:29 # 0x468614 adserver/util.init+0xf4 /home/jiyalei/serving_clean/backend/src/adserver/util/ip_helper.go:88 # 0x94cf80 adserver/nctrserver.init+0x40 /home/jiyalei/serving_clean/backend/src/adserver/nctrserver/nctrclient.go:42 # 0x78a4b7 adserver/bidserver.init+0x47 /home/jiyalei/serving_clean/backend/src/adserver/bidserver/spec.go:97 # 0x50f457 adserver/bucket.init+0x47 /home/jiyalei/serving_clean/backend/src/adserver/bucket/util.go:52 # 0x50a82b adserver/antispam.init+0x3b /home/jiyalei/serving_clean/backend/src/adserver/antispam/antispam.go:201 # 0x466017 adserver/server.init+0x47 /home/jiyalei/serving_clean/backend/src/adserver/server/adserver_handler.go:356 # 0x4019cb main.init+0x3b /home/jiyalei/serving_clean/backend/src/adserver/adserver.go:36 # 0x42fa2f runtime.main+0x27f /data/opt/go/src/runtime/proc.go:177 解读三 # runtime.MemStats # Alloc = 232368464 // 系统分配了,并且仍在使用的内存 # TotalAlloc = 515498984 // 分配的内存总量 # Sys = 379609648 // 从系统得到的内存总量 # Lookups…
thumbnail
golang cpu性能统计解读
依赖前提 程序中引入pprof包后可以直接查看线上的服务性能数据 import ( _ "net/http/pprof" ) 触发方法 # 比如程序对外暴露8001端口 go tool pprof http://127.0.0.1:8001/debug/pprof/profile?seconds=60 结果解读 如果没有添加统计时间参数,则触发之后会默认进入30s统计阶段,之后进入交互是界面,可以查看性能数据。我们先这样看,top -cum,是按照cum列按占用时间顺序显示前10个。 [@zyz-4 ~]$ go tool pprof http://localhost:8001/debug/pprof/profile Fetching profile from http://localhost:8001/debug/pprof/profile Please wait... (30s) Saved profile in /data/home/jiyalei/pprof/pprof.localhost:8001.samples.cpu.002.pb.gz Entering interactive mode (type "help" for commands) (pprof) top -cum 10ms of 80ms total (12.50%) Showing top 10 nodes out of 55 (cum >= 10ms) flat flat% sum% cum cum% 0 0% 0% 60ms 75.00% runtime.goexit 0 0% 0% 30ms 37.50% dproxy/helper.(*DataManager).StartRun 0 0% 0% 30ms 37.50% dproxy/helper.(*DataManager).realRun 10ms 12.50% 12.50% 30ms 37.50% dproxy/helper.(*DataManager).startRealTimer 0 0% 12.50% 20ms 25.00% dproxy/helper.(*RpcClient).RealGetFunc 0 0% 12.50% 10ms 12.50% dproxy/helper.(*RpcClient).GetNewClient 0 0% 12.50% 10ms 12.50% encoding/gob.(*Encoder).Encode 0 0% 12.50% 10ms 12.50% encoding/gob.(*Encoder).EncodeValue 0 0% 12.50% 10ms 12.50% encoding/gob.(*Encoder).encode 0 0% 12.50% 10ms 12.50% encoding/gob.(*Encoder).encodeStruct (pprof) top命令默认显示10个,可以通过topN的方式显示N个,cum参数表示按照cum列排序。 flat的意思是本地取样计数,cum的意思是累积取样计数,具体含义引用自官方文档: 本地取样计数和累积取样计数。本地取样计数的含义是当前函数在取样中直接出现的次数。累积取样计数的含义是当前函数以及当前函数直接或间接调用的函数在取样中直接出现的次数。所以,存在这样一种场景:对于一个函数来说,它的本地取样计数是0。因为它没有在取样中直接出现过。但是,由于它直接或间接调用的函数频繁的直接出现在取样中,所以这个函数的累积取样计数却会很高。我们以上图中的函数mian.main为例。由于main.main函数在所有取样中都没有直接出现过,所以它的本地取样计数为0。但又由于它是命令源码文件中的入口函数,程序中其他的函数都直接或间接的被它调用。所以,它的累积取样计数是所有函数中最高的。注意,不论是本地取样计数还是累积取样计数都没有把函数对自身的调用计算在内。函数对自身的调用又被称为递归调用. 我们通常看累计取样计数cum的时间,可以大致体现出来当前的耗时。可以通过list + 函数名的方式查看具体函数内部的占用时间,仅限于“触发方法二”: (pprof) list StartRun Total: 70ms ROUTINE ======================== dproxy/helper.(*DataManager).StartRun in /home/jiyalei/serving_clean/backend/src/dproxy/helper/dataManager.go 0 10ms (flat, cum) 14.29% of Total . . 72:} . . 73: . . 74://run DataManager on caller's gorountine, so caller will be blocked . . 75:func (self *DataManager) StartRun() { . .…
thumbnail
白话原码、反码、补码的产生
数字在自然界中抽象出来的时候,一棵树,两只猪,是没有正数和负数的概念的。计算机保存最原始的数字,也是没有正和负的数字,叫没符号数字。如果我们在内存分配4位(bit)去存放无符号数字,是下面这样子的: 后来在生活中为了表示“欠别人钱”这个概念,就从无符号数中,划分出了“正数”和“负数”。正如上帝一挥手,从混沌中划分了“白天”与“黑夜”。为了表示正与负,人们发明了"原码",把生活应该有的正负概念,原原本本的表示出来。把左边第一位腾出位置,存放符号,正用0来表示,负用1来表示: 但使用“原码”储存的方式,方便了看的人类,却苦了计算机 我们希望 (+1)和(-1)相加是0,但计算机只能算出0001+1001=1010 (-2),这不是我们想要的结果 。 (╯' - ')╯︵ ┻━┻ 另外一个问题,这里有一个(+0)和(-0)。 为了解决“正负相加等于0”的问题,在“原码”的基础上,人们发明了“反码”。 “反码”表示方式是用来处理负数的,符号位置不变,其余位置相反: 当“原码”变成“反码”时,完美的解决了“正负相加等于0”的问题。过去的(+1)和(-1)相加,变成了0001+1101=1111,刚好反码表示方式中,1111象征-0。 人们总是进益求精,历史遗留下来的问题,有两个零存在,+0 和 -0。 我们希望只有一个0,所以发明了"补码",同样是针对"负数"做处理的"补码"的意思是,从原来"反码"的基础上,补充一个新的代码,(+1)我们的目标是,没有蛀牙(-0): 有得必有失,在补一位1的时候,要丢掉最高位我们要处理"反码"中的"-0",当1111再补上一个1之后,变成了10000,丢掉最高位就是0000,刚好和左边正数的0,完美融合掉了。 这样就解决了+0和-0同时存在的问题另外"正负数相加等于0"的问题,同样得到满足举例,3和(-3)相加,0011 + 1101 =10000,丢掉最高位,就是0000(0)同样有失必有得,我们失去了(-0) , 收获了(-8)以上就是"补码"的存在方式。 结论:保存正负数,不断改进方案后,选择了最好的"补码"方案。 参考 知乎用户
thumbnail
golang设定最长执行时间的几种方法
前言 web服务开发过程中经常遇到我们需要某一个逻辑最长执行多少时间,超过这个时间需要快速返回而不是继续等待,常用的有这么几种方法 方法一 ch := make(chan bool, 1) timeout := make(chan bool, 1) // Send a message to our timeout channel after 1s go func() { time.Sleep(1 * time.Second) timeout <- true }() go func() { // do something ch <- true }() // Wait for a message, or timeout select { case <-ch: fmt.Println("Read from ch") case <-timeout: fmt.Println("Timed out") } 一句话评价为快速但是资源有浪费,这是最容易想到的方法,但是不足之处在于第一个协程总是需要等待1s,即使我们需要的处理很快就完成,有一定的资源浪费,而且资源没有回收,都需要等待gc。 方法二 ch := make(chan bool, 1) go func() { // do something ch <- true close(ch) }() select { case <-ch: fmt.Println("Read from ch") case <-time.After(1 * time.Second): fmt.Println("Timed out") } 在方法一的基础上优化了一些,提前close了ch,这样如果处理很快完成,ch也很块被释放,但是定时器好像还是仍然需要等1s后才可以交给gc去释放。 方法三 ch := make(chan bool, 1) go func() { ch <- true close(ch) }() timer := time.NewTimer(1 * time.Second) defer timer.Stop() select { case <-ch: fmt.Println("Read from ch") case <-timer.C: fmt.Println("Timed out") } 在方法二的基础上继续优化,定时器修改为可以提前终止的方式,这样我们是主动的去释放掉所有临时的资源了,因此推荐这种方法。 综述 最终完整代码见这个,以接口的方式提供。 var ErrTimeout = errors.New("timeout") func RunWithTimeout(handler func(), timeout time.Duration) error { done := make(chan bool, 1) go func() { handler() done <- true close(done) }() timer := time.NewTimer(timeout) defer timer.Stop() select { case <-timer.C: return ErrTimeout case <-done: return nil } }
thumbnail
gorm enum字段问题
开发时碰到了mysql中字段类型为enum中,比如下面的gender字段 CREATE TABLE `user` ( `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增ID', `account` varchar(255) NOT NULL COMMENT '登录账号', `gender` enum('no','male','female') NOT NULL DEFAULT 'no' COMMENT '性别', PRIMARY KEY (`ID`), ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin AUTO_INCREMENT=1 COMMENT='用户表' 对应字段类型看似是string类型的,因此定义结构体定义为了 type User struct { Id int Account string Gender string } 但是在用gorm库create表记录是有如下错误: newUser := &db.User{ Account: "Test", } if err := sqlDB.Create(newUser).Error; err != nil { return err } // err为 Error 1265: Data Truncated 猜想到的问题原因是 Data Truncated通常是因为字段类型不匹配导致的,这里因为设定gender为空,和mysql定义的默认值为no产生了冲突,gorm默认按照string的默认值去insert记录也就出问题了,这里其实需要告诉gorm该字段类型的定义的,因此添加上mysql的标记,让gorm默认值和mysql定义的一致 type User struct { Id int Account string Gender string `gorm:"type:enum('no','male','female');default:no"` } 之后在插入(create)记录就没问题了。
thumbnail
详解goroutine调度
我们都知道Go语言是原生支持语言级并发的,这个并发的最小逻辑单元就是goroutine。goroutine就是Go语言提供的一种用户态线程,当然这种用户态线程是跑在内核级线程之上的。当我们创建了很多的goroutine,并且它们都是跑在同一个内核线程之上的时候,就需要一个调度器来维护这些goroutine,确保所有的goroutine都使用cpu,并且是尽可能公平的使用cpu资源。 这个调度器的原理以及实现值得我们去深入研究一下。支撑整个调度器的主要有4个重要结构,分别是M、G、P、Sched,前三个定义在runtime.h中,Sched定义在proc.c中。 - Sched结构就是调度器,它维护有存储M和G的队列以及调度器的一些状态信息等。 - M代表内核级线程,一个M就是一个线程,goroutine就是跑在M之上的;M是一个很大的结构,里面维护小对象内存cache(mcache)、当前执行的goroutine、随机数发生器等等非常多的信息。 - P全称是Processor,处理器,它的主要用途就是用来执行goroutine的,所以它也维护了一个goroutine队列,里面存储了所有需要它来执行的goroutine,这个P的角色可能有一点让人迷惑,一开始容易和M冲突,后面重点聊一下它们的关系。 - G就是goroutine实现的核心结构了,G维护了goroutine需要的栈、程序计数器以及它所在的M等信息。 理解M、P、G三者的关系对理解整个调度器非常重要,我从网络上找了一个图来说明其三者关系: 地鼠(gopher)用小车运着一堆待加工的砖。M就可以看作图中的地鼠,P就是小车,G就是小车里装的砖。一图胜千言啊,弄清楚了它们三者的关系,下面我们就开始重点聊地鼠是如何在搬运砖块的。 启动过程 在关心绝大多数程序的内部原理的时候,我们都试图去弄明白其启动初始化过程,弄明白这个过程对后续的深入分析至关重要。在asm_amd64.s文件中的汇编代码_rt0_amd64就是整个启动过程,核心过程如下: CALL runtime·args(SB) CALL runtime·osinit(SB) CALL runtime·hashinit(SB) CALL runtime·schedinit(SB) // create a new goroutine to start program PUSHQ $runtime·main·f(SB) // entry PUSHQ $0 // arg size CALL runtime·newproc(SB) POPQ AX POPQ AX // start this M CALL runtime·mstart(SB) 启动过程做了调度器初始化runtime·schedinit后,调用runtime·newproc创建出第一个goroutine,这个goroutine将执行的函数是runtime·main,这第一个goroutine也就是所谓的主goroutine。我们写的最简单的Go程序”hello,world”就是完全跑在这个goroutine里,当然任何一个Go程序的入口都是从这个goroutine开始的。最后调用的runtime·mstart就是真正的执行上一步创建的主goroutine。 启动过程中的调度器初始化runtime·schedinit函数主要根据用户设置的GOMAXPROCS值来创建一批小车(P),不管GOMAXPROCS设置为多大,最多也只能创建256个小车(P)。这些小车(p)初始创建好后都是闲置状态,也就是还没开始使用,所以它们都放置在调度器结构(Sched)的pidle字段维护的链表中存储起来了,以备后续之需。 查看runtime·main函数可以了解到主goroutine开始执行后,做的第一件事情是创建了一个新的内核线程(地鼠M),不过这个线程是一个特殊线程,它在整个运行期专门负责做特定的事情——系统监控(sysmon)。接下来就是进入Go程序的main函数开始Go程序的执行。 至此,Go程序就被启动起来开始运行了。一个真正干活的Go程序,一定创建有不少的goroutine,所以在Go程序开始运行后,就会向调度器添加goroutine,调度器就要负责维护好这些goroutine的正常执行。 创建goroutine(G) 在Go程序中,时常会有类似代码: go do_something() go关键字就是用来创建一个goroutine的,后面的函数就是这个goroutine需要执行的代码逻辑。go关键字对应到调度器的接口就是runtime·newproc。runtime·newproc干的事情很简单,就负责制造一块砖(G),然后将这块砖(G)放入当前这个地鼠(M)的小车(P)中。 每个新的goroutine都需要有一个自己的栈,G结构的sched字段维护了栈地址以及程序计数器等信息,这是最基本的调度信息,也就是说这个goroutine放弃cpu的时候需要保存这些信息,待下次重新获得cpu的时候,需要将这些信息装载到对应的cpu寄存器中。 假设这个时候已经创建了大量的goroutne,就轮到调度器去维护这些goroutine了。 创建内核线程(M) Go程序中没有语言级的关键字让你去创建一个内核线程,你只能创建goroutine,内核线程只能由runtime根据实际情况去创建。runtime什么时候创建线程?以地鼠运砖图来讲,砖(G)太多了,地鼠(M)又太少了,实在忙不过来,刚好还有空闲的小车(P)没有使用,那就从别处再借些地鼠(M)过来直到把小车(p)用完为止。这里有一个地鼠(M)不够用,从别处借地鼠(M)的过程,这个过程就是创建一个内核线程(M)。创建M的接口函数是: void newm(void (*fn)(void), P *p) newm函数的核心行为就是调用clone系统调用创建一个内核线程,每个内核线程的开始执行位置都是runtime·mstart函数。参数p就是一辆空闲的小车(p)。 每个创建好的内核线程都从runtime·mstart函数开始执行了,它们将用分配给自己小车去搬砖了。 调度核心 newm接口只是给新创建的M分配了一个空闲的P,也就是相当于告诉借来的地鼠(M)——“接下来的日子,你将使用1号小车搬砖,记住是1号小车;待会自己到停车场拿车。”,地鼠(M)去拿小车(P)这个过程就是acquirep。runtime·mstart在进入schedule之前会给当前M装配上P,runtime·mstart函数中的代码: } else if(m != &runtime·m0) { acquirep(m->nextp); m->nextp = nil; } schedule(); if分支的内容就是为当前M装配上P,nextp就是newm分配的空闲小车(P),只是到这个时候才真正拿到手罢了。没有P,M是无法执行goroutine的,就像地鼠没有小车无法运砖一样的道理。对应acquirep的动作是releasep,把M装配的P给载掉;活干完了,地鼠需要休息了,就把小车还到停车场,然后睡觉去。 地鼠(M)拿到属于自己的小车(P)后,就进入工场开始干活了,也就是上面的schedule调用。简化schedule的代码如下: static void schedule(void) { G *gp; gp = runqget(m->p); if(gp == nil) gp = findrunnable(); if (m->p->runqhead != m->p->runqtail && runtime·atomicload(&runtime·sched.nmspinning) == 0 && runtime·atomicload(&runtime·sched.npidle) > 0) // TODO: fast atomic wakep(); execute(gp); } schedule函数被我简化了太多,主要是我不喜欢贴大段大段的代码,因此只保留主干代码了。这里涉及到4大步逻辑: 1. runqget, 地鼠(M)试图从自己的小车(P)取出一块砖(G),当然结果可能失败,也就是这个地鼠的小车已经空了,没有砖了。 2. findrunnable, 如果地鼠自己的小车中没有砖,那也不能闲着不干活是吧,所以地鼠就会试图跑去工场仓库取一块砖来处理;工场仓库也可能没砖啊,出现这种情况的时候,这个地鼠也没有偷懒停下干活,而是悄悄跑出去,随机盯上一个小伙伴(地鼠),然后从它的车里试图偷一半砖到自己车里。如果多次尝试偷砖都失败了,那说明实在没有砖可搬了,这个时候地鼠就会把小车还回停车场,然后睡觉休息了。如果地鼠睡觉了,下面的过程当然都停止了,地鼠睡觉也就是线程sleep了。 3. wakep, 到这个过程的时候,可怜的地鼠发现自己小车里有好多砖啊,自己根本处理不过来;再回头一看停车场居然有闲置的小车,立马跑到宿舍一看,你妹,居然还有小伙伴在睡觉,直接给屁股一脚,“你妹,居然还在睡觉,老子都快累死了,赶紧起来干活,分担点工作。”,小伙伴醒了,拿上自己的小车,乖乖干活去了。有时候,可怜的地鼠跑到宿舍却发现没有在睡觉的小伙伴,于是会很失望,最后只好向工场老板说——”停车场还有闲置的车啊,我快干不动了,赶紧从别的工场借个地鼠来帮忙吧。”,最后工场老板就搞来一个新的地鼠干活了。 4. execute,地鼠拿着砖放入火种欢快的烧练起来。 注: “地鼠偷砖”叫work stealing,一种调度算法。 到这里,貌似整个工场都正常的运转起来了,无懈可击的样子。不对,还有一个疑点没解决啊,假设地鼠的车里有很多砖,它把一块砖放入火炉中后,何时把它取出来,放入第二块砖呢?难道要一直把第一块砖烧练好,才取出来吗?那估计后面的砖真的是等得花儿都要谢了。这里就是要真正解决goroutine的调度,上下文切换问题。 调度点 当我们翻看channel的实现代码可以发现,对channel读写操作的时候会触发调用runtime·park函数。goroutine调用park后,这个goroutine就会被设置位waiting状态,放弃cpu。被park的goroutine处于waiting状态,并且这个goroutine不在小车(P)中,如果不对其调用runtime·ready,它是永远不会再被执行的。除了channel操作外,定时器中,网络poll等都有可能park goroutine。 除了park可以放弃cpu外,调用runtime·gosched函数也可以让当前goroutine放弃cpu,但和park完全不同;gosched是将goroutine设置为runnable状态,然后放入到调度器全局等待队列(也就是上面提到的工场仓库,这下就明白为何工场仓库会有砖块(G)了吧)。 除此之外,就轮到系统调用了,有些系统调用也会触发重新调度。Go语言完全是自己封装的系统调用,所以在封装系统调用的时候,可以做不少手脚,也就是进入系统调用的时候执行entersyscall,退出后又执行exitsyscall函数。 也只有封装了entersyscall的系统调用才有可能触发重新调度,它将改变小车(P)的状态为syscall。还记一开始提到的sysmon线程吗?这个系统监控线程会扫描所有的小车(P),发现一个小车(P)处于了syscall的状态,就知道这个小车(P)遇到了goroutine在做系统调用,于是系统监控线程就会创建一个新的地鼠(M)去把这个处于syscall的小车给抢过来,开始干活,这样这个小车中的所有砖块(G)就可以绕过之前系统调用的等待了。被抢走小车的地鼠等系统调用返回后,发现自己的车没,不能继续干活了,于是只能把执行系统调用的goroutine放回到工场仓库,自己睡觉去了。 从goroutine的调度点可以看出,调度器还是挺粗暴的,调度粒度有点过大,公平性也没有想想的那么好。总之,这个调度器还是比较简单的。 现场处理 goroutine在cpu上换入换出,不断上下文切换的时候,必须要保证的事情就是保存现场和恢复现场,保存现场就是在goroutine放弃cpu的时候,将相关寄存器的值给保存到内存中;恢复现场就是在goroutine重新获得cpu的时候,需要从内存把之前的寄存器信息全部放回到相应寄存器中去。 goroutine在主动放弃cpu的时候(park/gosched),都会涉及到调用runtime·mcall函数,此函数也是汇编实现,主要将goroutine的栈地址和程序计数器保存到G结构的sched字段中,mcall就完成了现场保存。恢复现场的函数是runtime·gogocall,这个函数主要在execute中调用,就是在执行goroutine前,需要重新装载相应的寄存器。 参考 goroutine与调度器
thumbnail
通俗易懂 白话goroutine的实现
一个线程就是一个栈加一堆资源。操作系统一会让cpu跑线程A,一会让cpu跑线程B,靠A和B的栈来保存A和B的执行状态。 每个线程都有他自己的栈。但是线程又老贵了,花不起那个钱,所以go发明了goroutine。大致就是说给每个goroutine弄一个分配在heap里面的栈来模拟线程栈。 比方说有3个goroutine,A,B,C,就在heap上弄三个栈出来。然后Go让一个单线程的scheduler开始跑他们仨。相当于 { A(); B(); C() },连续的,串行的跑。 和操作系统不太一样的是,操作系统可以随时随地把你线程停掉,切换到另一个线程。这个单线程的scheduler没那个能力啊,他就是user space的一段朴素的代码,他跑着A的时候控制权是在A的代码里面的。A自己不退出谁也没办法。所以A跑一小段后需要主动说,老大(scheduler),我不想跑了,帮我把我的所有的状态保存在我自己的栈上面,让我歇一会吧。这时候你可以看做A返回了。A返回了B就可以跑了,然后B跑一小段说,跑够了,保存状态,返回,然后C再跑。C跑一段也返回了。这样跑完{A(); B(); C()}之后,我们发现,好像他们都只跑了一小段啊。所以外面要包一个循环,大致是: goroutine_list = [A, B, C] while(goroutine): for goroutine in goroutine_list: r = goroutine() if r.finished(): goroutine_list.remove(r) 比如跑完一圈A,B,C之后谁也没执行完,那么就在回到A执行一次。由于我们把A的栈保存在了HEAP里,这时候可以把A的栈复制粘贴会系统栈里(我很确定真实情况不是这么玩的,会意就行),然后再调用A,这时候由于A是跑到一半自己说跳出来的,所以会从刚刚跳出来的地方继续执行。 比如A的内部大致上是这样 def A: 上次跑到的地方 = 找到上次跑哪儿了 读取所有临时变量 goto 上次跑到的地方 a = 1 print("do something") go.scheduler.保存程序指针 // 设置"这次跑哪儿了" go.scheduler.保存临时变量们 go.scheduler.跑够了_换人 //相当于return print("do something again") print(a) 第一次跑A,由于这是第一次,会打印do something,然后保存临时变量a,并保存跑到的地方,然后返回。再跑一次A,他会找到上次返回的地方的下一句,然后恢复临时变量a,然后接着跑,会打印“do something again"和1所以你看出来了,这个关键就在于每个goroutine跑一跑就要让一让。 一般支持这种玩意(叫做coroutine)的语言都是让每个coroutine自己说,我跑够了,换人。goroutine比较文艺的地方就在于,他可以来帮你判断啥时候“跑够了”。其中有一大半就是靠的你说的“异步并发”。 go把每一个能异步并发的操作,像你说的文件访问啦,网络访问啦之类的都包包好,包成一个看似朴素的而且是同步的“方法”,比如string readFile(我瞎举得例子)。但是神奇的地方在于,这个方法里其实会调用“异步并发”的操作,比如某操作系统提供的asyncReadFile。你也知道,这种异步方法都是很快返回的。所以你自己在某个goroutine里写了string s = go.file.readFile("/root") 其实go偷偷在里面执行了某操作系统的API asyncReadFIle。跑起来之后呢,这个方法就会说,我当前所在的goroutine跑够啦,把刚刚跑的那个异步操作的结果保存下下,换人: // 实际上 handler h = someOS.asyncReadFile("/root") //很快返回一个handler while (!h.finishedAsyncReadFile()): //很快返回Y/N go.scheduler.保存现状() go.scheduler.跑够了_换人() // 相当于return,不过下次会从这里的下一句开始执行 string s = h.getResultFromAsyncRead() 然后scheduler就换下一个goroutine跑了。等下次再跑回刚才那个goroutine的时候,他就看看,说那个asyncReadFile到底执行完没有啊,如果没有,就再换个人吧。如果执行完了,那就把结果拿出来,该干嘛干嘛。所以你看似写了个同步的操作,已经被go替换成异步操作了。 还有另外一种情况是,某个goroutine执行了某个不能异步调用的会blocking的系统调用,这个时候goroutine就没法玩那种异步调用的把戏了。他会把你挪到一个真正的线程里让你在那个县城里等着,他接茬去跑别的goroutine。比如A这么定义 def A: print("do something") go.os.InvokeSomeReallyHeavyAndBlockingSystemCall() print("do something 2") go会帮你转成def 真实的A: print("do something") Thread t = new Thread( () => { SomeReallyHeavyAndBlockingSystemCall(); }) t.start() while !t.finished(): go.scheduler.保存现状 go.scheduler.跑够了_换人 print("finished") 所以真实的A还是不会blocking,还是可以跟别的小伙伴(goroutine)愉快地玩耍(轮流往复的被执行),但他其实已经占了一个真是的系统线程了。当然会有一种情况就是A完全没有调用任何可能的“异步并发”的操作,也没有调用任何的同步的系统调用,而是一个劲的用CPU做运算(比如用个死循环调用a++)。在早期的go里,这个A就把整个程序block住了。后面新版本的go好像会有一些处理办法,比如如果你A里面call了任意一个别的函数的话,就有一定几率被踢下去换人。好像也可以自己主动说我要换人的,可以去查查新的go的spec。 部分代码举例: // 源文件:go/src/runtime/proc.go if s == _Psyscall { // 备注:goroutine 中触发系统调用的情况 // Retake P from syscall if it's there for more than 1 sysmon tick (at least 20us). ========================================================== t := int64(_p_.syscalltick) if int64(pd.syscalltick) != t { pd.syscalltick = uint32(t) pd.syscallwhen = now continue } ... (省略) ... if atomic.Cas(&_p_.status, s, _Pidle) { ... (省略) ... handoffp(_p_) // 备注:切换 P 实体…
thumbnail
通过一道题谈谈golang的组合
我们从这个例子说起 type People struct{} func (p *People) ShowA() { fmt.Println("showA") p.ShowB() } func (p *People) ShowB() { fmt.Println("showB") } type Teacher struct { People } func (t *Teacher) ShowB() { fmt.Println("teacher showB") } func main() { t := Teacher{} t.ShowA() } 这个题的结果是 showA showB 最开始我判断错了,误以为按照重载的概念来讲,想到的结果是 showA teacher showB golang官方从来没说支持继承、重载之类的,以上例子中只是叫组合而已,只是把另外一个结构体的方法组合过来,底层实现上只是加了一个匿名类型的People。 也就是说main里面的t.ShowA(),实际上只是隐藏里组合过来的匿名类型的People,我们展开来看实际底层是这么调用的,t.People.ShowA(),只不过t.People是匿名了,简化为t.ShowA(),因此t.ShowA()里面调用的p.ShowB()实际上是t.People.ShowB(),也就是showB的结果了。 以上可以看出这种实际上是匿名组合而已,和继承不是一个概念,继承是将基类的方法都继承过来,组合当然不是了。
thumbnail
如何理解持续集成 持续集成是什么
因为最近项目因为新特性修改导致老特性失效的问题,这里也就谈谈持续集成的问题,小公司一般都不注重持续集成,这一环节缺失导致质量很难把控。 一、概念 持续集成指的是,频繁地(一天多次)将代码集成到主干。 它的好处主要有两个。 - 快速发现错误。每完成一点更新,就集成到主干,可以快速发现错误,定位错误也比较容易。 - 防止分支大幅偏离主干。如果不是经常集成,主干又在不断更新,会导致以后集成的难度变大,甚至难以集成。 持续集成的目的,就是让产品可以快速迭代,同时还能保持高质量。它的核心措施是,代码集成到主干之前,必须通过自动化测试。只要有一个测试用例失败,就不能集成。 Martin Fowler说过,"持续集成并不能消除Bug,而是让它们非常容易发现和改正。" 与持续集成相关的,还有两个概念,分别是持续交付和持续部署。 二、持续交付 持续交付(Continuous delivery)指的是,频繁地将软件的新版本,交付给质量团队或者用户,以供评审。如果评审通过,代码就进入生产阶段。 持续交付可以看作持续集成的下一步。它强调的是,不管怎么更新,软件是随时随地可以交付的。 三、持续部署 持续部署(continuous deployment)是持续交付的下一步,指的是代码通过评审以后,自动部署到生产环境。 持续部署的目标是,代码在任何时刻都是可部署的,可以进入生产阶段。 持续部署的前提是能自动化完成测试、构建、部署等步骤。它与持续交付的区别,可以参考下图。 四、流程 根据持续集成的设计,代码从提交到生产,整个过程有以下几步。 4.1 提交 流程的第一步,是开发者向代码仓库提交代码。所有后面的步骤都始于本地代码的一次提交(commit)。 4.2 测试(第一轮) 代码仓库对commit操作配置了钩子(hook),只要提交代码或者合并进主干,就会跑自动化测试。 测试有好几种。 - 单元测试:针对函数或模块的测试 - 集成测试:针对整体产品的某个功能的测试,又称功能测试 - 端对端测试:从用户界面直达数据库的全链路测试 第一轮至少要跑单元测试。 4.3 构建 通过第一轮测试,代码就可以合并进主干,就算可以交付了。 交付后,就先进行构建(build),再进入第二轮测试。所谓构建,指的是将源码转换为可以运行的实际代码,比如安装依赖,配置各种资源(样式表、JS脚本、图片)等等。 4.4 测试(第二轮) 构建完成,就要进行第二轮测试。如果第一轮已经涵盖了所有测试内容,第二轮可以省略,当然,这时构建步骤也要移到第一轮测试前面。 第二轮是全面测试,单元测试和集成测试都会跑,有条件的话,也要做端对端测试。所有测试以自动化为主,少数无法自动化的测试用例,就要人工跑。 需要强调的是,新版本的每一个更新点都必须测试到。如果测试的覆盖率不高,进入后面的部署阶段后,很可能会出现严重的问题。 4.5 部署 通过了第二轮测试,当前代码就是一个可以直接部署的版本(artifact)。将这个版本的所有文件打包( tar filename.tar * )存档,发到生产服务器。 生产服务器将打包文件,解包成本地的一个目录,再将运行路径的符号链接(symlink)指向这个目录,然后重新启动应用。 4.6 回滚 一旦当前版本发生问题,就要回滚到上一个版本的构建结果。最简单的做法就是修改一下符号链接,指向上一个版本的目录。