0%

解压密码:30a9cd357a436d80cd05db0dd5ba4067

数组长度和容量固定且相同

类型值

一个类型的实例称为次类型的一个值

一个类型可以有很多不同的值,其中一个为它的零值

每个类型都会有自己的零值,零值可以看做是这个类型的默认值

指针

当一个变量被声明的时候,go运行时将为此变量开辟一段内存,此内存的起始地址即为此变量的地址

go易错分析1

1. 强大的go接口interface

下面是一个go方法,其中的参数都是通过interface进行定义的

1
2
3
func CpSrcToDst(src io.Reader, dst io.Writer) (err error) {
return nil
}
常见的行为
1
2
3
4
5
func Interface interface{
Len() int
Less(i,j int) bool
Swap(i,j int)
}
解耦
1
2
3
4
5
6
7
8
// PaymentInterface 定义支付接口,所有具体的支付方式都需要实现这个接口
type PaymentInterface interface {
Pay(amount float64) error
}
// ProcessPayment 处理支付的函数,通过接口调用具体的支付逻辑,不依赖具体支付方式的实现细节
func ProcessPayment(payer PaymentInterface, amount float64) error {
return payer.Pay(amount)
}
限制行为
1
2
3
4
// 传递只读的接口
type CfgGetter interface{
Get() int
}

大多数情况下,接口应该作用于消费者端

返回一个接口,会限制灵活性;

做什么要保守,接收什么要自由;返回结构体而不是接口(大多数),尽可能的接收接口;

Welcome to Hexo! This is your very first post. Check documentation for more info. If you get any problems when using Hexo, you can find the answer in troubleshooting or you can ask me on GitHub.

Quick Start

Create a new post

1
$ hexo new "My New Post"

More info: Writing

Run server

1
$ hexo server

More info: Server

Generate static files

1
$ hexo generate

More info: Generating

Deploy to remote sites

1
$ hexo deploy

More info: Deployment

HTTPS(SSL/TLS)的加密机制虽然是大家都应了解的基本知识,但网上很多相关文章总会忽略一些内容,没有阐明完整的逻辑脉络,我学习它的时候也曾废了些功夫。

对称与非对称加密、数字签名、数字证书等,在学习过程中,除了了解“它是什么”,你是否有想过“为什么是它”?我认为有必要搞清楚后者,否则你可能只是单纯地记住了被灌输的知识,而未真正理解它。

本文以问题的形式逐步展开,一步步解开HTTPS的面纱,希望能帮助你彻底搞懂HTTPS!

(阅读完需要花些时间,欢迎收藏、点赞~)

为什么需要加密?

因为http的内容是明文传输的,明文数据会经过中间代理服务器、路由器、wifi热点、通信服务运营商等多个物理节点,如果信息在传输过程中被劫持,传输的内容就完全暴露了。劫持者还可以篡改传输的信息且不被双方察觉,这就是中间人攻击。所以我们才需要对信息进行加密。最容易理解的就是对称加密

什么是对称加密?

简单说就是有一个密钥,它可以加密一段信息,也可以对加密后的信息进行解密,和我们日常生活中用的钥匙作用差不多。

对称加密(https://sectigostore.com/blog/types-of-encryption-what-to-know-about-symmetric-vs-asymmetric-encryption/)

用对称加密可行吗?

如果通信双方都各自持有同一个密钥,且没有别人知道,这两方的通信安全当然是可以被保证的(除非密钥被破解)。

然而最大的问题就是这个密钥怎么让传输的双方知晓,同时不被别人知道。如果由服务器生成一个密钥并传输给浏览器,那在这个传输过程中密钥被别人劫持到手了怎么办?之后他就能用密钥解开双方传输的任何内容了,所以这么做当然不行。

换种思路?试想一下,如果浏览器内部就预存了网站A的密钥,且可以确保除了浏览器和网站A,不会有任何外人知道该密钥,那理论上用对称加密是可以的,这样浏览器只要预存好世界上所有HTTPS网站的密钥就行了!这么做显然不现实。
怎么办?所以我们就需要非对称加密

什么是非对称加密?

简单说就是有两把密钥,通常一把叫做公钥、一把叫私钥,用公钥加密的内容必须用私钥才能解开,同样,私钥加密的内容只有公钥能解开。

非对称加密(https://sectigostore.com/blog/types-of-encryption-what-to-know-about-symmetric-vs-asymmetric-encryption/)

用非对称加密可行吗?

鉴于非对称加密的机制,我们可能会有这种思路:服务器先把公钥以明文方式传输给浏览器,之后浏览器向服务器传数据前都先用这个公钥加密好再传,这条数据的安全似乎可以保障了!因为只有服务器有相应的私钥能解开公钥加密的数据

然而反过来由服务器到浏览器的这条路怎么保障安全?如果服务器用它的私钥加密数据传给浏览器,那么浏览器用公钥可以解密它,而这个公钥是一开始通过明文传输给浏览器的,若这个公钥被中间人劫持到了,那他也能用该公钥解密服务器传来的信息了。所以目前似乎只能保证由浏览器向服务器传输数据的安全性(其实仍有漏洞,下文会说),那利用这点你能想到什么解决方案吗?

改良的非对称加密方案,似乎可以?

我们已经理解通过一组公钥私钥,可以保证单个方向传输的安全性,那用两组公钥私钥,是否就能保证双向传输都安全了?请看下面的过程:

  1. 某网站服务器拥有公钥A与对应的私钥A’;浏览器拥有公钥B与对应的私钥B’。
  2. 浏览器把公钥B明文传输给服务器。
  3. 服务器把公钥A明文给传输浏览器。
  4. 之后浏览器向服务器传输的内容都用公钥A加密,服务器收到后用私钥A’解密。由于只有服务器拥有私钥A’,所以能保证这条数据的安全。
  5. 同理,服务器向浏览器传输的内容都用公钥B加密,浏览器收到后用私钥B’解密。同上也可以保证这条数据的安全。

的确可以!抛开这里面仍有的漏洞不谈(下文会讲),HTTPS的加密却没使用这种方案,为什么?很重要的原因是非对称加密算法非常耗时,而对称加密快很多。那我们能不能运用非对称加密的特性解决前面提到的对称加密的漏洞?

非对称加密+对称加密?

既然非对称加密耗时,那非对称加密+对称加密结合可以吗?而且得尽量减少非对称加密的次数。当然是可以的,且非对称加密、解密各只需用一次即可。
请看一下这个过程:

  1. 某网站拥有用于非对称加密的公钥A、私钥A’。
  2. 浏览器向网站服务器请求,服务器把公钥A明文给传输浏览器。
  3. 浏览器随机生成一个用于对称加密的密钥X,用公钥A加密后传给服务器。
  4. 服务器拿到后用私钥A’解密得到密钥X。
  5. 这样双方就都拥有密钥X了,且别人无法知道它。之后双方所有数据都通过密钥X加密解密即可。

完美!HTTPS基本就是采用了这种方案。完美?还是有漏洞的。

中间人攻击

中间人攻击(https://blog.pradeo.com/man-in-the-middle-attack)

如果在数据传输过程中,中间人劫持到了数据,此时他的确无法得到浏览器生成的密钥X,这个密钥本身被公钥A加密了,只有服务器才有私钥A’解开它,然而中间人却完全不需要拿到私钥A’就能干坏事了。请看:

  1. 某网站有用于非对称加密的公钥A、私钥A’。
  2. 浏览器向网站服务器请求,服务器把公钥A明文给传输浏览器。
  3. 中间人劫持到公钥A,保存下来,把数据包中的公钥A替换成自己伪造的公钥B(它当然也拥有公钥B对应的私钥B’)
  4. 浏览器生成一个用于对称加密的密钥X,用公钥B(浏览器无法得知公钥被替换了)加密后传给服务器。
  5. 中间人劫持后用私钥B’解密得到密钥X,再用公钥A加密后传给服务器
  6. 服务器拿到后用私钥A’解密得到密钥X。

这样在双方都不会发现异常的情况下,中间人通过一套“狸猫换太子”的操作,掉包了服务器传来的公钥,进而得到了密钥X。根本原因是浏览器无法确认收到的公钥是不是网站自己的,因为公钥本身是明文传输的,难道还得对公钥的传输进行加密?这似乎变成鸡生蛋、蛋生鸡的问题了。解法是什么?

如何证明浏览器收到的公钥一定是该网站的公钥?

其实所有证明的源头都是一条或多条不证自明的“公理”(可以回想一下数学上公理),由它推导出一切。比如现实生活中,若想证明某身份证号一定是小明的,可以看他身份证,而身份证是由政府作证的,这里的“公理”就是“政府机构可信”,这也是社会正常运作的前提。

那能不能类似地有个机构充当互联网世界的“公理”呢?让它作为一切证明的源头,给网站颁发一个“身份证”?

它就是CA机构,它是如今互联网世界正常运作的前提,而CA机构颁发的“身份证”就是数字证书

数字证书

网站在使用HTTPS前,需要向CA机构申领一份数字证书,数字证书里含有证书持有者信息、公钥信息等。服务器把证书传输给浏览器,浏览器从证书里获取公钥就行了,证书就如身份证,证明“该公钥对应该网站”。而这里又有一个显而易见的问题,“证书本身的传输过程中,如何防止被篡改”?即如何证明证书本身的真实性?身份证运用了一些防伪技术,而数字证书怎么防伪呢?解决这个问题我们就接近胜利了!

如何放防止数字证书被篡改?

我们把证书原本的内容生成一份“签名”,比对证书内容和签名是否一致就能判别是否被篡改。这就是数字证书的“防伪技术”,这里的“签名”就叫数字签名

数字签名

这部分内容建议看下图并结合后面的文字理解,图中左侧是数字签名的制作过程,右侧是验证过程:

数字签名的生成与验证(https://cheapsslsecurity.com/blog/digital-signature-vs-digital-certificate-the-difference-explained/)

数字签名的制作过程:

  1. CA机构拥有非对称加密的私钥和公钥。
  2. CA机构对证书明文数据T进行hash。
  3. 对hash后的值用私钥加密,得到数字签名S。

明文和数字签名共同组成了数字证书,这样一份数字证书就可以颁发给网站了。
那浏览器拿到服务器传来的数字证书后,如何验证它是不是真的?(有没有被篡改、掉包)

浏览器验证过程:

  1. 拿到证书,得到明文T,签名S。
  2. 用CA机构的公钥对S解密(由于是浏览器信任的机构,所以浏览器保有它的公钥。详情见下文),得到S’。
  3. 用证书里指明的hash算法对明文T进行hash得到T’。
  4. 显然通过以上步骤,T’应当等于S‘,除非明文或签名被篡改。所以此时比较S’是否等于T’,等于则表明证书可信。

为何么这样可以保证证书可信呢?我们来仔细想一下。

中间人有可能篡改该证书吗?

假设中间人篡改了证书的原文,由于他没有CA机构的私钥,所以无法得到此时加密后签名,无法相应地篡改签名。浏览器收到该证书后会发现原文和签名解密后的值不一致,则说明证书已被篡改,证书不可信,从而终止向服务器传输信息,防止信息泄露给中间人。

既然不可能篡改,那整个证书被掉包呢?

中间人有可能把证书掉包吗?

假设有另一个网站B也拿到了CA机构认证的证书,它想劫持网站A的信息。于是它成为中间人拦截到了A传给浏览器的证书,然后替换成自己的证书,传给浏览器,之后浏览器就会错误地拿到B的证书里的公钥了,这确实会导致上文“中间人攻击”那里提到的漏洞?

其实这并不会发生,因为证书里包含了网站A的信息,包括域名,浏览器把证书里的域名与自己请求的域名比对一下就知道有没有被掉包了。

为什么制作数字签名时需要hash一次?

我初识HTTPS的时候就有这个疑问,因为似乎那里的hash有点多余,把hash过程去掉也能保证证书没有被篡改。

最显然的是性能问题,前面我们已经说了非对称加密效率较差,证书信息一般较长,比较耗时。而hash后得到的是固定长度的信息(比如用md5算法hash后可以得到固定的128位的值),这样加解密就快很多。

当然也有安全上的原因,这部分内容相对深一些,感兴趣的可以看这篇解答:crypto.stackexchange.com/a/12780

怎么证明CA机构的公钥是可信的?

你们可能会发现上文中说到CA机构的公钥,我几乎一笔带过,“浏览器保有它的公钥”,这是个什么保有法?怎么证明这个公钥是否可信?

让我们回想一下数字证书到底是干啥的?没错,为了证明某公钥是可信的,即“该公钥是否对应该网站”,那CA机构的公钥是否也可以用数字证书来证明?没错,操作系统、浏览器本身会预装一些它们信任的根证书,如果其中会有CA机构的根证书,这样就可以拿到它对应的可信公钥了。

实际上证书之间的认证也可以不止一层,可以A信任B,B信任C,以此类推,我们把它叫做信任链数字证书链。也就是一连串的数字证书,由根证书为起点,透过层层信任,使终端实体证书的持有者可以获得转授的信任,以证明身份。

另外,不知你们是否遇到过网站访问不了、提示需安装证书的情况?这里安装的就是根证书。说明浏览器不认给这个网站颁发证书的机构,那么你就得手动下载安装该机构的根证书(风险自己承担XD)。安装后,你就有了它的公钥,就可以用它验证服务器发来的证书是否可信了。

信任链(https://publib.boulder.ibm.com/tividd/td/TRM/GC32-1323-00/en_US/HTML/admin230.htm)

每次进行HTTPS请求时都必须在SSL/TLS层进行握手传输密钥吗?

这也是我当时的困惑之一,显然每次请求都经历一次密钥传输过程非常耗时,那怎么达到只传输一次呢?

服务器会为每个浏览器(或客户端软件)维护一个session ID,在TLS握手阶段传给浏览器,浏览器生成好密钥传给服务器后,服务器会把该密钥存到相应的session ID下,之后浏览器每次请求都会携带session ID,服务器会根据session ID找到相应的密钥并进行解密加密操作,这样就不必要每次重新制作、传输密钥了!

总结

可以看下这张图,梳理一下整个流程(SSL、TLS握手有一些区别,不同版本间也有区别,不过大致过程就是这样):

www.extremetech.com)

至此,我们已自上而下地打通了HTTPS加密的整体脉络以及核心知识点,不知你是否真正搞懂了HTTPS呢?
找几个时间,多看、多想、多理解几次就会越来越清晰的!
那么,下面的问题你是否已经可以解答了呢?

  1. 为什么要用对称加密+非对称加密?
  2. 为什么不能只用非对称加密?
  3. 为什么需要数字证书?
  4. 为什么需要数字签名?

当然,由于篇幅和能力所限,一些更深入的内容没有覆盖到。但我认为一般对于前后端开发人员来说,了解到这步就够了,有兴趣的可以再深入研究~如有疏漏之处,欢迎指出。

字符串底层实际上是指向字节切片的指针,所以不管是赋值还是传参都只会拷贝其指针

示例一:对切片的不当追加导致引用无法释放

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package main

import (
"fmt"
"runtime"
"time"
)

func main() {
// 创建一个大字符串
bigStr := make([]byte, 1024*1024)
for i := range bigStr {
bigStr[i] = 'a'
}
slice := []string{}
for {
// 将大字符串转换为字符串并追加到切片中
str := string(bigStr)
slice = append(slice, str)
if len(slice) > 10000 {
// 只是简单地重置切片长度,但原有元素的引用依然存在
slice = slice[:0]
}
runtime.GC()
time.Sleep(time.Second)
}
}

由于这些大字符串无法被垃圾回收器(GC)判定为不再使用(因为存在引用关系),随着循环不断进行,内存占用会持续增大,最终导致内存泄漏。正确的做法可以是当需要清理切片时,创建一个新的空切片重新赋值给 slice ,这样旧的底层数组没有了引用,就能被 GC 回收,比如 slice = []string{}

示例二:函数返回切片时引发的问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main

import (
"fmt"
"runtime"
"time"
)

func GetStringSlice() []string {
slice := make([]string, 0)
for i := 0; i < 1000; i++ {
bigStr := make([]byte, 1024)
for j := range bigStr {
bigStr[j] = 'b'
}
str := string(bigStr)
slice = append(slice, str)
}
return slice
}

func main() {
for {
_ = GetStringSlice()
runtime.GC()
time.Sleep(time.Second)
}
}
  • GetStringSlice 函数中,创建了包含大量大字符串的切片并返回。每次调用这个函数时,虽然函数执行结束了,但返回的切片如果被外部变量接收(即使在示例中只是用 _ 接收,实际情况可能会赋值给别的变量保存起来),其底层的字符串元素所占用的内存空间就一直被引用着。
  • 假如调用该函数的频率很高,那么大量的内存空间无法被及时释放,就会造成内存泄漏。解决办法可以考虑在函数调用者那边合理地处理返回的切片,比如只在需要的短暂时间内持有切片,使用完后将其赋值为 nil ,让 GC 可以回收对应的内存,例如可以在合适的地方添加 result := GetStringSlice(); defer func() { result = nil }() (假设 result 是接收返回切片的变量)这样的逻辑。
示例三:超大字符串的截取

如果对大量不同的超大字符串进行截取,我们就需要根据实际情况考虑这种字符串截取的内存成本问题,如果在超大字符串中截取一个小字符串,由于字符串的不可变性,拼接的动作会生成新的字符串副本,使得字符串的底层字节切片拷贝到了一个新的内存空间。这种方式夜之星进行一次内存拷贝,只会浪费一个字节的空间。

1
s1 := (" " + s[:20])[1:]
字节切片转换字符串会发生内存拷贝吗?

字节切片转字符串过程中会发生一次内存拷贝,这一点是我们需要留意的。字符串在转字节切片的过程是发生了内存拷贝的。

实际上,将字符串转换为字节切片 bytes ,会在内存中创建一个新的字节切片,并将字符串的内容复制到新的字节切片中。这样做是为了避免在字节切片中修改字符串的内容,因为字符串是不可变的。将字节切片s1强转成字符串s2同样也发生了内存拷贝。

如果要实现高效的字符串与字节切片之间的转换就需要减少或避免额外的内存分配操作。

可以通过使用 unsafe 包来实现字符串到字节切片的零拷贝转换。但是要注意,使用 unsafe 包是不安全的,因为它可以绕过 Go 语言的类型系统和内存安全检查。

在一般情况下,建议使用标准的字符串转换方法 []byte(str) 来实现字符串到字节切片的转换,这样是安全且易于理解的方式。

本篇文章来分析一下 Go 语言 HTTP 标准库是如何实现的。

转载请声明出处哦~,本篇文章发布于luozhiyun的博客:https://www.luozhiyun.com/archives/561

本文使用的go的源码1.15.7

基于HTTP构建的服务标准模型包括两个端,客户端(Client)和服务端(Server)。HTTP 请求从客户端发出,服务端接受到请求后进行处理然后将响应返回给客户端。所以http服务器的工作就在于如何接受来自客户端的请求,并向客户端返回响应。

一个典型的 HTTP 服务应该如图所示:

HTTP client#

在 Go 中可以直接通过 HTTP 包的 Get 方法来发起相关请求数据,一个简单例子:

1
2
3
4
5
6
7
8
9
10
Copyfunc main() {
resp, err := http.Get("http://httpbin.org/get?name=luozhiyun&age=27")
if err != nil {
fmt.Println(err)
return
}
defer resp.Body.Close()
body, _ := ioutil.ReadAll(resp.Body)
fmt.Println(string(body))
}

我们下面通过这个例子来进行分析。

HTTP 的 Get 方法会调用到 DefaultClient 的 Get 方法,DefaultClient 是 Client 的一个空实例,所以最后会调用到 Client 的 Get 方法:

Client 结构体#

1
2
3
4
5
6
Copytype Client struct { 
Transport RoundTripper
CheckRedirect func(req *Request, via []*Request) error
Jar CookieJar
Timeout time.Duration
}

Client 结构体总共由四个字段组成:

Transport:表示 HTTP 事务,用于处理客户端的请求连接并等待服务端的响应;

CheckRedirect:用于指定处理重定向的策略;

Jar:用于管理和存储请求中的 cookie;

Timeout:指定客户端请求的最大超时时间,该超时时间包括连接、任何的重定向以及读取相应的时间;

初始化请求#

1
2
3
4
5
6
7
8
9
Copyfunc (c *Client) Get(url string) (resp *Response, err error) {
// 根据方法名、URL 和请求体构建请求
req, err := NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
// 执行请求
return c.Do(req)
}

我们要发起一个请求首先需要根据请求类型构建一个完整的请求头、请求体、请求参数。然后才是根据请求的完整结构来执行请求。

NewRequest 初始化请求#

NewRequest 会调用到 NewRequestWithContext 函数上。这个函数会根据请求返回一个 Request 结构体,它里面包含了一个 HTTP 请求所有信息。

Request

Request 结构体有很多字段,我这里列举几个大家比较熟悉的字段:

NewRequestWithContext

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Copyfunc NewRequestWithContext(ctx context.Context, method, url string, body io.Reader) (*Request, error) {
...
// parse url
u, err := urlpkg.Parse(url)
if err != nil {
return nil, err
}
rc, ok := body.(io.ReadCloser)
if !ok && body != nil {
rc = ioutil.NopCloser(body)
}
u.Host = removeEmptyPort(u.Host)
req := &Request{
ctx: ctx,
Method: method,
URL: u,
Proto: "HTTP/1.1",
ProtoMajor: 1,
ProtoMinor: 1,
Header: make(Header),
Body: rc,
Host: u.Host,
}
...
return req, nil
}

NewRequestWithContext 函数会将请求封装成一个 Request 结构体并返回。

准备 http 发送请求#

如上图所示,Client 调用 Do 方法处理发送请求最后会调用到 send 函数中。

1
2
3
4
5
6
7
8
Copyfunc (c *Client) send(req *Request, deadline time.Time) (resp *Response, didTimeout func() bool, err error) {
resp, didTimeout, err = send(req, c.transport(), deadline)
if err != nil {
return nil, didTimeout, err
}
...
return resp, nil, nil
}

Transport#

Client 的 send 方法在调用 send 函数进行下一步的处理前会先调用 transport 方法获取 DefaultTransport 实例,该实例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Copyvar DefaultTransport RoundTripper = &Transport{
// 定义 HTTP 代理策略
Proxy: ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
DualStack: true,
}).DialContext,
ForceAttemptHTTP2: true,
// 最大空闲连接数
MaxIdleConns: 100,
// 空闲连接超时时间
IdleConnTimeout: 90 * time.Second,
// TLS 握手超时时间
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}

Transport 实现 RoundTripper 接口,该结构体会发送 http 请求并等待响应。

1
2
3
Copytype RoundTripper interface { 
RoundTrip(*Request) (*Response, error)
}

从 RoundTripper 接口我们也可以看出,该接口定义的 RoundTrip 方法会具体的处理请求,处理完毕之后会响应 Response。

回到我们上面的 Client 的 send 方法中,它会调用 send 函数,这个函数主要逻辑都交给 Transport 的 RoundTrip 方法来执行。

RoundTrip 会调用到 roundTrip 方法中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
Copyfunc (t *Transport) roundTrip(req *Request) (*Response, error) {
t.nextProtoOnce.Do(t.onceSetNextProtoDefaults)
ctx := req.Context()
trace := httptrace.ContextClientTrace(ctx)
...
for {
select {
case <-ctx.Done():
req.closeBody()
return nil, ctx.Err()
default:
}

// 封装请求
treq := &transportRequest{Request: req, trace: trace, cancelKey: cancelKey}
cm, err := t.connectMethodForRequest(treq)
if err != nil {
req.closeBody()
return nil, err
}
// 获取连接
pconn, err := t.getConn(treq, cm)
if err != nil {
t.setReqCanceler(cancelKey, nil)
req.closeBody()
return nil, err
}

// 等待响应结果
var resp *Response
if pconn.alt != nil {
// HTTP/2 path.
t.setReqCanceler(cancelKey, nil) // not cancelable with CancelRequest
resp, err = pconn.alt.RoundTrip(req)
} else {
resp, err = pconn.roundTrip(treq)
}
if err == nil {
resp.Request = origReq
return resp, nil
}
...
}
}

roundTrip 方法会做两件事情:

  1. 调用 Transport 的 getConn 方法获取连接;
  2. 在获取到连接后,调用 persistConn 的 roundTrip 方法等待请求响应结果;

获取连接 getConn#

getConn 有两个阶段:

  1. 调用 queueForIdleConn 获取空闲 connection;
  2. 调用 queueForDial 等待创建新的 connection;
1
Copyfunc (t *Transport) getConn(treq *transportRequest, cm connectMethod) (pc *persistConn, err error) {	req := treq.Request	trace := treq.trace	ctx := req.Context()	if trace != nil && trace.GetConn != nil {		trace.GetConn(cm.addr())	}		// 将请求封装成 wantConn 结构体	w := &wantConn{		cm:         cm,		key:        cm.key(),		ctx:        ctx,		ready:      make(chan struct{}, 1),		beforeDial: testHookPrePendingDial,		afterDial:  testHookPostPendingDial,	}	defer func() {		if err != nil {			w.cancel(t, err)		}	}() 	// 获取空闲连接	if delivered := t.queueForIdleConn(w); delivered {		pc := w.pc		...		t.setReqCanceler(treq.cancelKey, func(error) {})		return pc, nil	} 	// 创建连接	t.queueForDial(w) 	select {	// 获取到连接后进入该分支	case <-w.ready:		...		return w.pc, w.err	...}

获取空闲连接 queueForIdleConn#

成功获取到空闲 connection:

成功获取 connection 分为如下几步:

  1. 根据当前的请求的地址去空闲 connection 字典中查看存不存在空闲的 connection 列表;
  2. 如果能获取到空闲的 connection 列表,那么获取到列表的最后一个 connection;
  3. 返回;

获取不到空闲 connection:

当获取不到空闲 connection 时:

  1. 根据当前的请求的地址去空闲 connection 字典中查看存不存在空闲的 connection 列表;
  2. 不存在该请求的 connection 列表,那么将该 wantConn 加入到 等待获取空闲 connection 字典中;

从上面的图解应该就很能看出这一步会怎么操作了,这里简要的分析一下代码,让大家更清楚里面的逻辑:

1
Copyfunc (t *Transport) queueForIdleConn(w *wantConn) (delivered bool) {	if t.DisableKeepAlives {		return false	}	t.idleMu.Lock()	defer t.idleMu.Unlock() 	t.closeIdle = false	if w == nil { 		return false	} 	// 计算空闲连接超时时间	var oldTime time.Time	if t.IdleConnTimeout > 0 {		oldTime = time.Now().Add(-t.IdleConnTimeout)	}	// Look for most recently-used idle connection.	// 找到key相同的 connection 列表	if list, ok := t.idleConn[w.key]; ok {		stop := false		delivered := false		for len(list) > 0 && !stop {			// 找到connection列表最后一个			pconn := list[len(list)-1] 			// 检查这个 connection 是不是等待太久了			tooOld := !oldTime.IsZero() && pconn.idleAt.Round(0).Before(oldTime)			if tooOld { 				go pconn.closeConnIfStillIdle()			}			// 该 connection 被标记为 broken 或 闲置太久 continue			if pconn.isBroken() || tooOld { 				list = list[:len(list)-1]				continue			}			// 尝试将该 connection 写入到 w 中			delivered = w.tryDeliver(pconn, nil)			if delivered {				// 操作成功,需要将 connection 从空闲列表中移除				if pconn.alt != nil { 				} else { 					t.idleLRU.remove(pconn)					list = list[:len(list)-1]				}			}			stop = true		}		if len(list) > 0 {			t.idleConn[w.key] = list		} else {			// 如果该 key 对应的空闲列表不存在,那么将该key从字典中移除			delete(t.idleConn, w.key)		}		if stop {			return delivered		}	} 	// 如果找不到空闲的 connection	if t.idleConnWait == nil {		t.idleConnWait = make(map[connectMethodKey]wantConnQueue)	}  // 将该 wantConn 加入到 等待获取空闲 connection 字典中	q := t.idleConnWait[w.key] 	q.cleanFront()	q.pushBack(w)	t.idleConnWait[w.key] = q	return false}

上面的注释已经很清楚了,我这里就不再解释了。

建立连接 queueForDial#

在获取不到空闲连接之后,会尝试去建立连接,从上面的图大致可以看到,总共分为以下几个步骤:

  1. 在调用 queueForDial 方法的时候会校验 MaxConnsPerHost 是否未设置或已达上限;
    1. 检验不通过则将当前的请求放入到 connsPerHostWait 等待字典中;
  2. 如果校验通过那么会异步的调用 dialConnFor 方法创建连接;
  3. dialConnFor 方法首先会调用 dialConn 方法创建 TCP 连接,然后启动两个异步线程来处理读写数据,然后调用 tryDeliver 将连接绑定到 wantConn 上面。

下面进行代码分析:

1
Copyfunc (t *Transport) queueForDial(w *wantConn) {	w.beforeDial()	// 小于零说明无限制,异步建立连接	if t.MaxConnsPerHost <= 0 {		go t.dialConnFor(w)		return	}	t.connsPerHostMu.Lock()	defer t.connsPerHostMu.Unlock()	// 每个 host 建立的连接数没达到上限,异步建立连接	if n := t.connsPerHost[w.key]; n < t.MaxConnsPerHost {		if t.connsPerHost == nil {			t.connsPerHost = make(map[connectMethodKey]int)		}		t.connsPerHost[w.key] = n + 1		go t.dialConnFor(w)		return	}	//每个 host 建立的连接数已达到上限,需要进入等待队列	if t.connsPerHostWait == nil {		t.connsPerHostWait = make(map[connectMethodKey]wantConnQueue)	}	q := t.connsPerHostWait[w.key]	q.cleanFront()	q.pushBack(w)	t.connsPerHostWait[w.key] = q}

这里主要进行参数校验,如果最大连接数限制为零,亦或是每个 host 建立的连接数没达到上限,那么直接异步建立连接。

dialConnFor

1
Copyfunc (t *Transport) dialConnFor(w *wantConn) {	defer w.afterDial()	// 建立连接	pc, err := t.dialConn(w.ctx, w.cm)	// 连接绑定 wantConn	delivered := w.tryDeliver(pc, err)	// 建立连接成功,但是绑定 wantConn 失败	// 那么将该连接放置到空闲连接字典或调用 等待获取空闲 connection 字典 中的元素执行	if err == nil && (!delivered || pc.alt != nil) { 		t.putOrCloseIdleConn(pc)	}	if err != nil {		t.decConnsPerHost(w.key)	}}

dialConnFor 会调用 dialConn 进行 TCP 连接创建,创建完毕之后调用 tryDeliver 方法和 wantConn 进行绑定。

dialConn

1
Copyfunc (t *Transport) dialConn(ctx context.Context, cm connectMethod) (pconn *persistConn, err error) {	// 创建连接结构体	pconn = &persistConn{		t:             t,		cacheKey:      cm.key(),		reqch:         make(chan requestAndChan, 1),		writech:       make(chan writeRequest, 1),		closech:       make(chan struct{}),		writeErrCh:    make(chan error, 1),		writeLoopDone: make(chan struct{}),	}	...	if cm.scheme() == "https" && t.hasCustomTLSDialer() {		...	} else {		// 建立 tcp 连接		conn, err := t.dial(ctx, "tcp", cm.addr())		if err != nil {			return nil, wrapErr(err)		}		pconn.conn = conn 	} 	...	if s := pconn.tlsState; s != nil && s.NegotiatedProtocolIsMutual && s.NegotiatedProtocol != "" {		if next, ok := t.TLSNextProto[s.NegotiatedProtocol]; ok {			alt := next(cm.targetAddr, pconn.conn.(*tls.Conn))			if e, ok := alt.(http2erringRoundTripper); ok {				// pconn.conn was closed by next (http2configureTransport.upgradeFn).				return nil, e.err			}			return &persistConn{t: t, cacheKey: pconn.cacheKey, alt: alt}, nil		}	}	pconn.br = bufio.NewReaderSize(pconn, t.readBufferSize())	pconn.bw = bufio.NewWriterSize(persistConnWriter{pconn}, t.writeBufferSize())	//为每个连接异步处理读写数据	go pconn.readLoop()	go pconn.writeLoop()	return pconn, nil}

这里会根据 schema 的不同设置不同的连接配置,我上面显示的是我们常用的 HTTP 连接的创建过程。对于 HTTP 来说会建立 tcp 连接,然后为连接异步处理读写数据,最后将创建好的连接返回。

等待响应#

这一部分的内容会稍微复杂一些,但确实非常的有趣。

在创建连接的时候会初始化两个 channel :writech 负责写入请求数据,reqch负责读取响应数据。我们在上面创建连接的时候,也提到了会为连接创建两个异步循环 readLoop 和 writeLoop 来负责处理读写数据。

在获取到连接之后,会调用连接的 roundTrip 方法,它首先会将请求数据写入到 writech 管道中,writeLoop 接收到数据之后就会处理请求。

然后 roundTrip 会将 requestAndChan 结构体写入到 reqch 管道中,然后 roundTrip 会循环等待。readLoop 读取到响应数据之后就会通过 requestAndChan 结构体中保存的管道将数据封装成 responseAndError 结构体回写,这样 roundTrip 就可以接受到响应数据结束循环等待并返回。

roundTrip

1
Copyfunc (pc *persistConn) roundTrip(req *transportRequest) (resp *Response, err error) {	...	writeErrCh := make(chan error, 1)	// 将请求数据写入到 writech 管道中	pc.writech <- writeRequest{req, writeErrCh, continueCh}	// 用于接收响应的管道	resc := make(chan responseAndError)	// 将用于接收响应的管道封装成 requestAndChan 写入到 reqch 管道中	pc.reqch <- requestAndChan{		req:        req.Request,		cancelKey:  req.cancelKey,		ch:         resc,		...	}	...	for {		testHookWaitResLoop()		select { 		// 接收到响应数据		case re := <-resc:			if (re.res == nil) == (re.err == nil) {				panic(fmt.Sprintf("internal error: exactly one of res or err should be set; nil=%v", re.res == nil))			}			if debugRoundTrip {				req.logf("resc recv: %p, %T/%#v", re.res, re.err, re.err)			}			if re.err != nil {				return nil, pc.mapRoundTripError(req, startBytesWritten, re.err)			}			// 返回响应数据			return re.res, nil		...	}}

这里会封装好 writeRequest 作为发送请求的数据,并将用于接收响应的管道封装成 requestAndChan 写入到 reqch 管道中,然后循环等待接受响应。

然后 writeLoop 会进行请求数据 writeRequest :

1
Copyfunc (pc *persistConn) writeLoop() {	defer close(pc.writeLoopDone)	for {		select {		case wr := <-pc.writech:			startBytesWritten := pc.nwrite			// 向 TCP 连接中写入数据,并发送至目标服务器			err := wr.req.Request.write(pc.bw, pc.isProxy, wr.req.extra, pc.waitForContinue(wr.continueCh))			...		case <-pc.closech:			return		}	}}

这里会将从 writech 管道中获取到的数据写入到 TCP 连接中,并发送至目标服务器。

readLoop

1
Copyfunc (pc *persistConn) readLoop() {	closeErr := errReadLoopExiting // default value, if not changed below	defer func() {		pc.close(closeErr)		pc.t.removeIdleConn(pc)	}()	... 	alive := true	for alive {		pc.readLimit = pc.maxHeaderResponseSize()		// 获取 roundTrip 发送的结构体		rc := <-pc.reqch		trace := httptrace.ContextClientTrace(rc.req.Context())		var resp *Response		if err == nil {			// 读取数据			resp, err = pc.readResponse(rc, trace)		} else {			err = transportReadFromServerError{err}			closeErr = err		}		...  		// 将响应数据写回到管道中		select {		case rc.ch <- responseAndError{res: resp}:		case <-rc.callerGone:			return		}		...	}}

这里是从 TCP 连接中读取到对应的请求响应数据,通过 roundTrip 传入的管道再回写,然后 roundTrip 就会接受到数据并获取的响应数据返回。

http server#

我这里继续以一个简单的例子作为开头:

1
Copyfunc HelloHandler(w http.ResponseWriter, r *http.Request) {	fmt.Fprintf(w, "Hello World")}func main () {	http.HandleFunc("/", HelloHandler)	http.ListenAndServe(":8000", nil)}

在实现上面我先用一张图进行简要的介绍一下:

其实我们从上面例子的方法名就可以知道一些大致的步骤:

  1. 注册处理器到一个 hash 表中,可以通过键值路由匹配;
  2. 注册完之后就是开启循环监听,每监听到一个连接就会创建一个 Goroutine;
  3. 在创建好的 Goroutine 里面会循环的等待接收请求数据,然后根据请求的地址去处理器路由表中匹配对应的处理器,然后将请求交给处理器处理;

注册处理器#

处理器的注册如上面的例子所示,是通过调用 HandleFunc 函数来实现的。

HandleFunc 函数会一直调用到 ServeMux 的 Handle 方法中。

1
Copyfunc (mux *ServeMux) Handle(pattern string, handler Handler) {	mux.mu.Lock()	defer mux.mu.Unlock()	...	e := muxEntry{h: handler, pattern: pattern}	mux.m[pattern] = e	if pattern[len(pattern)-1] == '/' {		mux.es = appendSorted(mux.es, e)	}	if pattern[0] != '/' {		mux.hosts = true	}}

Handle 会根据路由作为 hash 表的键来保存 muxEntry 对象,muxEntry封装了 pattern 和 handler。如果路由表达式以'/'结尾,则将对应的muxEntry对象加入到[]muxEntry中。

hash 表是用于路由精确匹配,[]muxEntry用于部分匹配。

监听#

监听是通过调用 ListenAndServe 函数,里面会调用 server 的 ListenAndServe 方法:

1
Copyfunc (srv *Server) ListenAndServe() error {	if srv.shuttingDown() {		return ErrServerClosed	}	addr := srv.Addr	if addr == "" {		addr = ":http"	}    // 监听端口	ln, err := net.Listen("tcp", addr)	if err != nil {		return err	}    // 循环接收监听到的网络请求	return srv.Serve(ln)}

Serve

1
Copyfunc (srv *Server) Serve(l net.Listener) error { 	...	baseCtx := context.Background()  	ctx := context.WithValue(baseCtx, ServerContextKey, srv)	for {		// 接收 listener 过来的网络连接		rw, err := l.Accept()		... 		tempDelay = 0		c := srv.newConn(rw)		c.setState(c.rwc, StateNew) 		// 创建协程处理连接		go c.serve(connCtx)	}}

Serve 这个方法里面会用一个循环去接收监听到的网络连接,然后创建协程处理连接。所以难免就会有一个问题,如果并发很高的话,可能会一次性创建太多协程,导致处理不过来的情况。

处理请求#

处理请求是通过为每个连接创建 goroutine 来处理对应的请求:

1
Copyfunc (c *conn) serve(ctx context.Context) {	c.remoteAddr = c.rwc.RemoteAddr().String()	ctx = context.WithValue(ctx, LocalAddrContextKey, c.rwc.LocalAddr()) 	... 	ctx, cancelCtx := context.WithCancel(ctx)	c.cancelCtx = cancelCtx	defer cancelCtx() 	c.r = &connReader{conn: c}	c.bufr = newBufioReader(c.r)	c.bufw = newBufioWriterSize(checkConnErrorWriter{c}, 4<<10)  	for {		// 读取请求		w, err := c.readRequest(ctx) 		... 		// 根据请求路由调用处理器处理请求		serverHandler{c.server}.ServeHTTP(w, w.req)		w.cancelCtx()		if c.hijacked() {			return		}		w.finishRequest() 		...	}}

当一个连接建立之后,该连接中所有的请求都将在这个协程中进行处理,直到连接被关闭。在 for 循环里面会循环调用 readRequest 读取请求进行处理。

请求处理是通过调用 ServeHTTP 进行的:

1
Copytype serverHandler struct {   srv *Server}func (sh serverHandler) ServeHTTP(rw ResponseWriter, req *Request) {	handler := sh.srv.Handler	if handler == nil {		handler = DefaultServeMux	}	if req.RequestURI == "*" && req.Method == "OPTIONS" {		handler = globalOptionsHandler{}	}	handler.ServeHTTP(rw, req)}

serverHandler 其实就是 Server 包装了一层。这里的 sh.srv.Handler参数实际上是传入的 ServeMux 实例,所以这里最后会调用到 ServeMux 的 ServeHTTP 方法。

最终会通过 handler 调用到 match 方法进行路由匹配:

1
Copyfunc (mux *ServeMux) match(path string) (h Handler, pattern string) {	v, ok := mux.m[path]	if ok {		return v.h, v.pattern	} 	for _, e := range mux.es {		if strings.HasPrefix(path, e.pattern) {			return e.h, e.pattern		}	}	return nil, ""}

这个方法里首先会利用进行精确匹配,如果匹配成功那么直接返回;匹配不成功,那么会根据 []muxEntry中保存的和当前路由最接近的已注册的父节点路由进行匹配,否则继续匹配下一个父节点路由,直到根路由/。最后会调用对应的处理器进行处理。

Reference#

https://cloud.tencent.com/developer/article/1515297

https://duyanghao.github.io/http-transport/

https://draveness.me/golang/docs/part4-advanced/ch09-stdlib/golang-net-http

https://laravelacademy.org/post/21003

https://segmentfault.com/a/1190000021653550

tcp会偶尔3秒timeout的分析以及如何用php规避这个问题

这是一篇好文章,随着蘑菇街的完蛋,蘑菇街技术博客也没了,所以特意备份一下这篇

2年前做一个cache中间件调用的时候,发现很多通过php的curl调用一个的服务会出现偶尔的connect_time超时, 表现为get_curlinfo的connect_time在3秒左右, 本来没怎么注意, 因为客户端的curl_timeout设置的就是3秒, 某天, 我把这个timeout改到了5秒后, 发现了一个奇怪的现象, 很多慢请求依旧表现为connect_time在3秒左右..看来这个3秒并不是因为客户端设置的timeout引起的.于是开始查找这个原因.


首先, 凭借经验调整了linux内核关于tcp的几个参数

1
2
net.core.netdev_max_backlog = 862144
net.core.somaxconn = 262144

经过观察发现依旧会有3秒超时, 而且数量并没有减少.

第二步, 排除是大并发导致的问题, 在一台空闲机器上也部署同样的服务, 仅让线上一台机器跑空闲机器的服务, 结果发现依旧会有报错.排除并发导致的问题.

最后, 通过查了大量的资料才发现并不是我们才遇到过这个问题, 而且这个问题并不是curl的问题, 它影响到所有tcp的调用, 网上各种说法, 但结论都指向linux内核对于tcp的实现.(某些版本会出现这些问题), 有兴趣的可以看下下面这两个资料.
资料1
资料2

一看深入到linux内核..不管怎样修改的成本一定很大..于是乎, 发挥我们手中的php来规避这个问题的时间到了.

原本的代码, 简单实现,常规curl调用:

1
2
3
4
5
6
7
8
9
10
function curl_call($p1, $p2 ...) {
$ch = curl_init();
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
curl_setopt($ch, CURLOPT_URL, 'http://demon.at');
$res = curl_exec($ch);
if (false === $res) {
//失败..抛异常..
}
return $res;
}

可以看出, 如果用上面的代码, 无法避免3秒connect_time的问题..这种实现对curl版本会有要求(CURLOPT_CONNECTTIMEOUT_MS),主要的思路是,通过对链接时间进行毫秒级的控制(因为超时往往发生在connect的时候),加上失败重试机制,来最大限度保证调用的正确性。所以,下面的代码就诞生了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function curl_call($p1, $p2, $times = 1) {
$ch = curl_init();
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
curl_setopt($ch, CURLOPT_URL, 'http://demon.at');
$curl_version = curl_version();
if ($curl_version['version_number'] >= 462850) {
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT_MS, 20);
curl_setopt($ch, CURLOPT_NOSIGNAL, 1);
} else {
throw new Exception('this curl version is too low, version_num : '
. $curl_version['version']);
}
$res = curl_exec($ch);
curl_close($ch);
if (false === $res) {
if (curl_errno($ch) == CURLE_OPERATION_TIMEOUTED
and $times != 最大重试阀值 ) {
$times += 1;
return curl_call($p1, $p2, $times);
}
}

return $res;
}

上面这段代码只是一个规避的简单实例, 一些小细节并没有可以完善..比如抛出异常常以后curl资源的手动释放等等..这里不做讨论..当然还漏了一点要说的是,对重试次数最好加上限制 :)

说明一下上面几个数字值的含义:

1
2
462850 //因为php的CURLOPT_CONNECTTIMEOUT_MS需要 curl_version 7.16.2,这个值就是这个版本的数字版本号,还需要注意的是, php版本要大于5.2.3
20 //连接超时的时间, 单位:ms

这样这个问题就这样通过php的代码来规避开了.
如果有对这个问题有更好的解决方法,欢迎指教.


总结

tcp connect 的流程是这样的
1、tcp发出SYN建链报文后,报文到ip层需要进行路由查询
2、路由查询完成后,报文到arp层查询下一跳mac地址
3、如果本地没有对应网关的arp缓存,就需要缓存住这个报文,发起arp请求
4、arp层收到arp回应报文之后,从缓存中取出SYN报文,完成mac头填写并发送给驱动。

问题在于,arp层缓存队列长度默认为3。如果你运气不好,刚好赶上缓存已满,这个报文就会被丢弃。

TCP层发现SYN报文发出去3s(默认值)还没有回应,就会重发一个SYN。这就是为什么少数连接会3s后才能建链。

幸运的是,arp层缓存队列长度是可配置的,用 sysctl -a | grep unres_qlen 就能看到,默认值为3。

select 核心数据结构

Go 语言在实现 select 语句时,依赖于几个关键的数据结构,其中最重要的是 hselect 结构体(不同 Go 版本中名称和具体细节可能略有差异),它大致包含以下重要成员:

  • cases 数组:用来存放 select 语句中所有的 case 相关信息,每个元素对应一个 case,记录了诸如该 case 涉及的 channel、是发送操作还是接收操作等详细属性。
  • **ncase**:表示 select 语句中总的 case 数量,也就是 cases 数组中元素的个数。
  • **randseq**:用于在多个 channel 同时就绪时,实现随机选择 case 的功能,它存储了一个随机序列相关的信息,帮助确定选择顺序。

运行时执行过程

  • 初始化与轮询
    当程序运行到 select 语句对应的代码时,运行时系统首先会基于编译阶段准备好的 hselect 结构体实例进行初始化操作,获取各个 case 的相关信息。然后进入轮询阶段,运行时会遍历 cases 数组中的每一个元素(也就是每个 case),通过调用底层与 channel 相关的函数(比如判断 channel 是否已关闭、是否有数据可接收、是否能发送数据等)来检查对应的 channel 操作是否可以立即执行。
  • 阻塞与唤醒机制
    • 如果在第一轮轮询时所有 case 对应的 channel 操作都不能立即执行,并且不存在 default 语句,那么当前 goroutine(Go 语言中的轻量级线程)就会进入阻塞状态。运行时系统会将这个 goroutine 放置到对应 channel 的等待队列中(每个 channel 都有相关的等待队列用于存放等待该 channel 操作的 goroutine),等待相关 channel 状态发生变化而被唤醒。
    • 一旦某个 channel 的状态改变使得对应的 channel 操作可以执行了(例如有数据被写入到一个原本空的等待接收的 channel 中,或者一个原本满的等待发送的 channel 腾出了空间等情况),运行时系统就会从该 channel 的等待队列中唤醒相关的 goroutine,让其继续执行 select 语句中对应的 case 操作。
  • 随机选择策略(多个 channel 同时就绪时)
    当有多个 channel 的操作在同一时刻都可以执行时,为了保证公平性以及避免一些特定的执行顺序问题,Go 语言采用了基于 randseq 相关机制的随机选择策略。它并不是简单的完全随机,而是按照运行时系统内部设定的一种规则,利用 randseq 中记录的随机序列信息,从多个就绪的 case 中选择一个来执行,确保不同 case 在多次出现同时就绪的情况下都有相对均等的执行机会。
  • default 语句处理
    如果在轮询 cases 数组时发现所有 channel 操作都不能立即执行,但 select 语句中存在 default 语句,那么运行时系统会直接跳过对 channel 的等待,执行 default 语句块中的内容,之后程序继续正常往下执行,不会阻塞当前 goroutine

内存管理与回收

在整个 select 语句执行完毕后,相关的临时数据结构(比如 hselect 结构体实例等)所占用的内存空间会根据 Go 语言的内存管理机制,由垃圾回收器(GC)适时地进行回收处理,确保内存资源的有效利用。

总体而言,Go 语言的 select 语句底层通过编译阶段的精心转换以及运行时复杂的轮询、阻塞唤醒、随机选择等机制,实现了在多个 channel 通信操作间灵活、高效且公平地做出选择,保障了 Go 程序在并发场景下处理多通道通信的顺畅性和可靠性。不同 Go 版本在一些细节上可能会对上述原理的实现有所优化和微调,但基本的核心逻辑是相似的。

分支流程控制语句的语言跟switch-case类似,但是它不支持fallthrough语句,而且当多个case都满足的情况,并不是从上到下依次执

行,而是随机顺序执行的。每个case关键字后必须跟随一个通道接收数据操作或者一个通道发送数据操作。