解压密码:30a9cd357a436d80cd05db0dd5ba4067
数组长度和容量固定且相同
类型值
一个类型的实例称为次类型的一个值
一个类型可以有很多不同的值,其中一个为它的零值
每个类型都会有自己的零值,零值可以看做是这个类型的默认值
指针
当一个变量被声明的时候,go运行时将为此变量开辟一段内存,此内存的起始地址即为此变量的地址
下面是一个go方法,其中的参数都是通过interface进行定义的
1 | func CpSrcToDst(src io.Reader, dst io.Writer) (err error) { |
1 | func Interface interface{ |
1 | // PaymentInterface 定义支付接口,所有具体的支付方式都需要实现这个接口 |
1 | // 传递只读的接口 |
大多数情况下,接口应该作用于消费者端
返回一个接口,会限制灵活性;
做什么要保守,接收什么要自由;返回结构体而不是接口(大多数),尽可能的接收接口;
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.
1 | $ hexo new "My New Post" |
More info: Writing
1 | $ hexo server |
More info: Server
1 | $ hexo generate |
More info: Generating
1 | $ hexo deploy |
More info: Deployment
HTTPS(SSL/TLS)的加密机制虽然是大家都应了解的基本知识,但网上很多相关文章总会忽略一些内容,没有阐明完整的逻辑脉络,我学习它的时候也曾废了些功夫。
对称与非对称加密、数字签名、数字证书等,在学习过程中,除了了解“它是什么”,你是否有想过“为什么是它”?我认为有必要搞清楚后者,否则你可能只是单纯地记住了被灌输的知识,而未真正理解它。
本文以问题的形式逐步展开,一步步解开HTTPS的面纱,希望能帮助你彻底搞懂HTTPS!
(阅读完需要花些时间,欢迎收藏、点赞~)
因为http的内容是明文传输的,明文数据会经过中间代理服务器、路由器、wifi热点、通信服务运营商等多个物理节点,如果信息在传输过程中被劫持,传输的内容就完全暴露了。劫持者还可以篡改传输的信息且不被双方察觉,这就是中间人攻击
。所以我们才需要对信息进行加密。最容易理解的就是对称加密
。
简单说就是有一个密钥,它可以加密一段信息,也可以对加密后的信息进行解密,和我们日常生活中用的钥匙作用差不多。
如果通信双方都各自持有同一个密钥,且没有别人知道,这两方的通信安全当然是可以被保证的(除非密钥被破解)。
然而最大的问题就是这个密钥怎么让传输的双方知晓,同时不被别人知道。如果由服务器生成一个密钥并传输给浏览器,那在这个传输过程中密钥被别人劫持到手了怎么办?之后他就能用密钥解开双方传输的任何内容了,所以这么做当然不行。
换种思路?试想一下,如果浏览器内部就预存了网站A的密钥,且可以确保除了浏览器和网站A,不会有任何外人知道该密钥,那理论上用对称加密是可以的,这样浏览器只要预存好世界上所有HTTPS网站的密钥就行了!这么做显然不现实。
怎么办?所以我们就需要非对称加密
。
简单说就是有两把密钥,通常一把叫做公钥、一把叫私钥,用公钥加密的内容必须用私钥才能解开,同样,私钥加密的内容只有公钥能解开。
鉴于非对称加密的机制,我们可能会有这种思路:服务器先把公钥以明文方式传输给浏览器,之后浏览器向服务器传数据前都先用这个公钥加密好再传,这条数据的安全似乎可以保障了!因为只有服务器有相应的私钥能解开公钥加密的数据。
然而反过来由服务器到浏览器的这条路怎么保障安全?如果服务器用它的私钥加密数据传给浏览器,那么浏览器用公钥可以解密它,而这个公钥是一开始通过明文传输给浏览器的,若这个公钥被中间人劫持到了,那他也能用该公钥解密服务器传来的信息了。所以目前似乎只能保证由浏览器向服务器传输数据的安全性(其实仍有漏洞,下文会说),那利用这点你能想到什么解决方案吗?
我们已经理解通过一组公钥私钥,可以保证单个方向传输的安全性,那用两组公钥私钥,是否就能保证双向传输都安全了?请看下面的过程:
的确可以!抛开这里面仍有的漏洞不谈(下文会讲),HTTPS的加密却没使用这种方案,为什么?很重要的原因是非对称加密算法非常耗时,而对称加密快很多。那我们能不能运用非对称加密的特性解决前面提到的对称加密的漏洞?
既然非对称加密耗时,那非对称加密+对称加密结合可以吗?而且得尽量减少非对称加密的次数。当然是可以的,且非对称加密、解密各只需用一次即可。
请看一下这个过程:
完美!HTTPS基本就是采用了这种方案。完美?还是有漏洞的。
中间人攻击(https://blog.pradeo.com/man-in-the-middle-attack)
如果在数据传输过程中,中间人劫持到了数据,此时他的确无法得到浏览器生成的密钥X,这个密钥本身被公钥A加密了,只有服务器才有私钥A’解开它,然而中间人却完全不需要拿到私钥A’就能干坏事了。请看:
这样在双方都不会发现异常的情况下,中间人通过一套“狸猫换太子”的操作,掉包了服务器传来的公钥,进而得到了密钥X。根本原因是浏览器无法确认收到的公钥是不是网站自己的,因为公钥本身是明文传输的,难道还得对公钥的传输进行加密?这似乎变成鸡生蛋、蛋生鸡的问题了。解法是什么?
其实所有证明的源头都是一条或多条不证自明的“公理”(可以回想一下数学上公理),由它推导出一切。比如现实生活中,若想证明某身份证号一定是小明的,可以看他身份证,而身份证是由政府作证的,这里的“公理”就是“政府机构可信”,这也是社会正常运作的前提。
那能不能类似地有个机构充当互联网世界的“公理”呢?让它作为一切证明的源头,给网站颁发一个“身份证”?
它就是CA机构,它是如今互联网世界正常运作的前提,而CA机构颁发的“身份证”就是数字证书。
网站在使用HTTPS前,需要向CA机构申领一份数字证书,数字证书里含有证书持有者信息、公钥信息等。服务器把证书传输给浏览器,浏览器从证书里获取公钥就行了,证书就如身份证,证明“该公钥对应该网站”。而这里又有一个显而易见的问题,“证书本身的传输过程中,如何防止被篡改”?即如何证明证书本身的真实性?身份证运用了一些防伪技术,而数字证书怎么防伪呢?解决这个问题我们就接近胜利了!
我们把证书原本的内容生成一份“签名”,比对证书内容和签名是否一致就能判别是否被篡改。这就是数字证书的“防伪技术”,这里的“签名”就叫数字签名
:
这部分内容建议看下图并结合后面的文字理解,图中左侧是数字签名的制作过程,右侧是验证过程:
数字签名的制作过程:
明文和数字签名共同组成了数字证书,这样一份数字证书就可以颁发给网站了。
那浏览器拿到服务器传来的数字证书后,如何验证它是不是真的?(有没有被篡改、掉包)
浏览器验证过程:
为何么这样可以保证证书可信呢?我们来仔细想一下。
假设中间人篡改了证书的原文,由于他没有CA机构的私钥,所以无法得到此时加密后签名,无法相应地篡改签名。浏览器收到该证书后会发现原文和签名解密后的值不一致,则说明证书已被篡改,证书不可信,从而终止向服务器传输信息,防止信息泄露给中间人。
既然不可能篡改,那整个证书被掉包呢?
假设有另一个网站B也拿到了CA机构认证的证书,它想劫持网站A的信息。于是它成为中间人拦截到了A传给浏览器的证书,然后替换成自己的证书,传给浏览器,之后浏览器就会错误地拿到B的证书里的公钥了,这确实会导致上文“中间人攻击”那里提到的漏洞?
其实这并不会发生,因为证书里包含了网站A的信息,包括域名,浏览器把证书里的域名与自己请求的域名比对一下就知道有没有被掉包了。
我初识HTTPS的时候就有这个疑问,因为似乎那里的hash有点多余,把hash过程去掉也能保证证书没有被篡改。
最显然的是性能问题,前面我们已经说了非对称加密效率较差,证书信息一般较长,比较耗时。而hash后得到的是固定长度的信息(比如用md5算法hash后可以得到固定的128位的值),这样加解密就快很多。
当然也有安全上的原因,这部分内容相对深一些,感兴趣的可以看这篇解答:crypto.stackexchange.com/a/12780
你们可能会发现上文中说到CA机构的公钥,我几乎一笔带过,“浏览器保有它的公钥”,这是个什么保有法?怎么证明这个公钥是否可信?
让我们回想一下数字证书到底是干啥的?没错,为了证明某公钥是可信的,即“该公钥是否对应该网站”,那CA机构的公钥是否也可以用数字证书来证明?没错,操作系统、浏览器本身会预装一些它们信任的根证书,如果其中会有CA机构的根证书,这样就可以拿到它对应的可信公钥了。
实际上证书之间的认证也可以不止一层,可以A信任B,B信任C,以此类推,我们把它叫做信任链
或数字证书链
。也就是一连串的数字证书,由根证书为起点,透过层层信任,使终端实体证书的持有者可以获得转授的信任,以证明身份。
另外,不知你们是否遇到过网站访问不了、提示需安装证书的情况?这里安装的就是根证书。说明浏览器不认给这个网站颁发证书的机构,那么你就得手动下载安装该机构的根证书(风险自己承担XD)。安装后,你就有了它的公钥,就可以用它验证服务器发来的证书是否可信了。
信任链(https://publib.boulder.ibm.com/tividd/td/TRM/GC32-1323-00/en_US/HTML/admin230.htm)
这也是我当时的困惑之一,显然每次请求都经历一次密钥传输过程非常耗时,那怎么达到只传输一次呢?
服务器会为每个浏览器(或客户端软件)维护一个session ID,在TLS握手阶段传给浏览器,浏览器生成好密钥传给服务器后,服务器会把该密钥存到相应的session ID下,之后浏览器每次请求都会携带session ID,服务器会根据session ID找到相应的密钥并进行解密加密操作,这样就不必要每次重新制作、传输密钥了!
可以看下这张图,梳理一下整个流程(SSL、TLS握手有一些区别,不同版本间也有区别,不过大致过程就是这样):
至此,我们已自上而下地打通了HTTPS加密的整体脉络以及核心知识点,不知你是否真正搞懂了HTTPS呢?
找几个时间,多看、多想、多理解几次就会越来越清晰的!
那么,下面的问题你是否已经可以解答了呢?
当然,由于篇幅和能力所限,一些更深入的内容没有覆盖到。但我认为一般对于前后端开发人员来说,了解到这步就够了,有兴趣的可以再深入研究~如有疏漏之处,欢迎指出。
字符串底层实际上是指向字节切片的指针,所以不管是赋值还是传参都只会拷贝其指针
1 | package main |
由于这些大字符串无法被垃圾回收器(GC)判定为不再使用(因为存在引用关系),随着循环不断进行,内存占用会持续增大,最终导致内存泄漏。正确的做法可以是当需要清理切片时,创建一个新的空切片重新赋值给 slice
,这样旧的底层数组没有了引用,就能被 GC 回收,比如 slice = []string{}
1 | package main |
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 服务应该如图所示:
在 Go 中可以直接通过 HTTP 包的 Get 方法来发起相关请求数据,一个简单例子:
1 | Copyfunc main() { |
我们下面通过这个例子来进行分析。
HTTP 的 Get 方法会调用到 DefaultClient 的 Get 方法,DefaultClient 是 Client 的一个空实例,所以最后会调用到 Client 的 Get 方法:
1 | Copytype Client struct { |
Client 结构体总共由四个字段组成:
Transport:表示 HTTP 事务,用于处理客户端的请求连接并等待服务端的响应;
CheckRedirect:用于指定处理重定向的策略;
Jar:用于管理和存储请求中的 cookie;
Timeout:指定客户端请求的最大超时时间,该超时时间包括连接、任何的重定向以及读取相应的时间;
1 | Copyfunc (c *Client) Get(url string) (resp *Response, err error) { |
我们要发起一个请求首先需要根据请求类型构建一个完整的请求头、请求体、请求参数。然后才是根据请求的完整结构来执行请求。
NewRequest 会调用到 NewRequestWithContext 函数上。这个函数会根据请求返回一个 Request 结构体,它里面包含了一个 HTTP 请求所有信息。
Request
Request 结构体有很多字段,我这里列举几个大家比较熟悉的字段:
NewRequestWithContext
1 | Copyfunc NewRequestWithContext(ctx context.Context, method, url string, body io.Reader) (*Request, error) { |
NewRequestWithContext 函数会将请求封装成一个 Request 结构体并返回。
如上图所示,Client 调用 Do 方法处理发送请求最后会调用到 send 函数中。
1 | Copyfunc (c *Client) send(req *Request, deadline time.Time) (resp *Response, didTimeout func() bool, err error) { |
Client 的 send 方法在调用 send 函数进行下一步的处理前会先调用 transport 方法获取 DefaultTransport 实例,该实例如下:
1 | Copyvar DefaultTransport RoundTripper = &Transport{ |
Transport 实现 RoundTripper 接口,该结构体会发送 http 请求并等待响应。
1 | Copytype RoundTripper interface { |
从 RoundTripper 接口我们也可以看出,该接口定义的 RoundTrip 方法会具体的处理请求,处理完毕之后会响应 Response。
回到我们上面的 Client 的 send 方法中,它会调用 send 函数,这个函数主要逻辑都交给 Transport 的 RoundTrip 方法来执行。
RoundTrip 会调用到 roundTrip 方法中:
1 | Copyfunc (t *Transport) roundTrip(req *Request) (*Response, error) { |
roundTrip 方法会做两件事情:
getConn 有两个阶段:
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 ...} |
成功获取到空闲 connection:
成功获取 connection 分为如下几步:
获取不到空闲 connection:
当获取不到空闲 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} |
上面的注释已经很清楚了,我这里就不再解释了。
在获取不到空闲连接之后,会尝试去建立连接,从上面的图大致可以看到,总共分为以下几个步骤:
下面进行代码分析:
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 就会接受到数据并获取的响应数据返回。
我这里继续以一个简单的例子作为开头:
1 | Copyfunc HelloHandler(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "Hello World")}func main () { http.HandleFunc("/", HelloHandler) http.ListenAndServe(":8000", nil)} |
在实现上面我先用一张图进行简要的介绍一下:
其实我们从上面例子的方法名就可以知道一些大致的步骤:
处理器的注册如上面的例子所示,是通过调用 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
中保存的和当前路由最接近的已注册的父节点路由进行匹配,否则继续匹配下一个父节点路由,直到根路由/
。最后会调用对应的处理器进行处理。
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
这是一篇好文章,随着蘑菇街的完蛋,蘑菇街技术博客也没了,所以特意备份一下这篇
2年前做一个cache中间件调用的时候,发现很多通过php的curl调用一个的服务会出现偶尔的connect_time超时, 表现为get_curlinfo的connect_time在3秒左右, 本来没怎么注意, 因为客户端的curl_timeout设置的就是3秒, 某天, 我把这个timeout改到了5秒后, 发现了一个奇怪的现象, 很多慢请求依旧表现为connect_time在3秒左右..看来这个3秒并不是因为客户端设置的timeout引起的.于是开始查找这个原因.
首先, 凭借经验调整了linux内核关于tcp的几个参数
1 | net.core.netdev_max_backlog = 862144 |
经过观察发现依旧会有3秒超时, 而且数量并没有减少.
第二步, 排除是大并发导致的问题, 在一台空闲机器上也部署同样的服务, 仅让线上一台机器跑空闲机器的服务, 结果发现依旧会有报错.排除并发导致的问题.
最后, 通过查了大量的资料才发现并不是我们才遇到过这个问题, 而且这个问题并不是curl的问题, 它影响到所有tcp的调用, 网上各种说法, 但结论都指向linux内核对于tcp的实现.(某些版本会出现这些问题), 有兴趣的可以看下下面这两个资料.
资料1
资料2
一看深入到linux内核..不管怎样修改的成本一定很大..于是乎, 发挥我们手中的php来规避这个问题的时间到了.
原本的代码, 简单实现,常规curl调用:
1 | function curl_call($p1, $p2 ...) { |
可以看出, 如果用上面的代码, 无法避免3秒connect_time的问题..这种实现对curl版本会有要求(CURLOPT_CONNECTTIMEOUT_MS),主要的思路是,通过对链接时间进行毫秒级的控制(因为超时往往发生在connect的时候),加上失败重试机制,来最大限度保证调用的正确性。所以,下面的代码就诞生了:
1 | function curl_call($p1, $p2, $times = 1) { |
上面这段代码只是一个规避的简单实例, 一些小细节并没有可以完善..比如抛出异常常以后curl资源的手动释放等等..这里不做讨论..当然还漏了一点要说的是,对重试次数最好加上限制 :)
说明一下上面几个数字值的含义:
1 | 462850 //因为php的CURLOPT_CONNECTTIMEOUT_MS需要 curl_version 7.16.2,这个值就是这个版本的数字版本号,还需要注意的是, php版本要大于5.2.3 |
这样这个问题就这样通过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。
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关键字后必须跟随一个通道接收数据操作或者一个通道发送数据操作。