sync包
Golang sync包
Golang的sync包经常用于并发场景,本文介绍sync包的大致用法,许多特性和功能其实与POSIX API中的内容大同小异,只是sync包针对的是goroutine,相当于是golang层的用于并行场景的API。
互斥锁 Mutex
1 | func (m *Mutex) Lock() |
互斥锁只能同时被一个 goroutine 锁定,其它 goroutine 将阻塞直到互斥锁被解锁(重新争抢对互斥锁的锁定)
1 | package main |
读写锁 RWMutex
读写操作的互斥锁,读写锁与互斥锁最大的不同就是可以分别对读、写进行锁定。一般用在大量读操作、少量写操作的情况:
1 | func (rw *RWMutex) Lock() |
- 写锁定(Lock),对写操作进行锁定
- 写解锁(Unlock),对写锁定进行解锁
- 读锁定(RLock),对读操作进行锁定
- 读解锁(RUnlock),对读锁定进行解锁
在首次使用之后,不要复制该读写锁。不要混用锁定和解锁,如:Lock 和 RUnlock、RLock 和 Unlock。因为对未读锁定的读写锁进行读解锁或对未写锁定的读写锁进行写解锁将会引起运行时错误。
如何理解读写锁呢?
- 同时只能有一个 goroutine 能够获得写锁定。
- 同时可以有任意多个 gorouinte 获得读锁定。
- 同时只能存在写锁定或读锁定(读和写互斥)。
其实很好理解,我在写的时候,不允许别人同时写(容易竞争错乱),也不允许别人读(容易读错数据)。我在读的时候,大家可以一起读(分享嘛~),但是不能有人写(我们读的人读错了怎么办)。
例子如下:
1 | package main |
WaitGroup
WaitGroup 用于等待一组 goroutine 结束,用法很简单。它有三个方法:
1 | func (wg *WaitGroup) Add(delta int) |
其中Done()是Add(-1)的别名。简单的来说,使用Add()添加计数,Done()减掉一个计数,计数不为0, 阻塞Wait()的运行。
例子:同时开三个协程去请求网页, 等三个请求都完成后才继续 Wait 之后的工作。
1 | var wg sync.WaitGroup |
Cond条件锁
与POSIX标准的用法差不多
1 | cond.L.Lock() |
- cond.L.Lock()和cond.L.Unlock():也可以使用lock.Lock()和lock.Unlock(),完全一样,因为是指针转递
- cond.Wait():Unlock() -> 阻塞等待通知(即等待Signal()或Broadcast()的通知) -> 收到通知 -> Lock()
- cond.Signal():通知一个Wait()了的,若没有Wait(),也不会报错。Signal()通知的顺序是根据原来加入通知列表(Wait())的先入先出
- cond.Broadcast(): 通知所有Wait()了的,若没有Wait(),也不会报错
有文章提议用与生产者消费者模式。(但是某些实际情况可能会导致最后一个锁永远没人用)。
绿色框就是Wait()的实际动作
Pool 临时对象池
要说Pool的作用的话,还是先得从栈和碓说起。
- 栈区(stack)— 由编译器自动分配释放,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。
- 堆区(heap) — 一般由程序员分配释放,若程序员不释放,程序结束时可能由OS回收,golang当中就是垃圾回收(GC)。
1 | func F() { |
类似于上面代码里面的temp变量,只是内函数内部申请的临时变量,并不会作为返回值返回,它就是被编译器申请到栈里面。申请到栈内存好处:函数返回直接释放,不会引起垃圾回收,对性能没有影响。
1 | func F() []int{ |
而上面这段代码,申请的代码一模一样,但是申请后作为返回值返回了,编译器会认为变量之后还会被使用,当函数返回之后并不会将其内存归还,那么它就会被申请到堆上面了。申请到堆上面的内存才会引起垃圾回收。
1 | func F() { |
a和b代码一样,就是申请的空间不一样大,但是它们两个的命运是截然相反的。a前面已经介绍过,会申请到栈上面,而b,由于申请的内存较大,编译器会把这种申请内存较大的变量转移到堆上面。即使是临时变量,申请过大也会在堆上面申请。
而c,对我们而言其含义和a是一致的,但是编译器对于这种不定长度的申请方式,也会在堆上面申请,即使申请的长度很短。
实际项目基本都是通过c := make([]int, 0, l)来申请内存,长度都是不确定的。自然而然这些变量都会申请到堆上面了。Golang使用的垃圾回收算法是『标记——清除』。简单得说,就是程序要从操作系统申请一块比较大的内存,内存分成小块,通过链表链接。每次程序申请内存,就从链表上面遍历每一小块,找到符合的就返回其地址,没有合适的就从操作系统再申请。如果申请内存次数较多,而且申请的大小不固定,就会引起内存碎片化的问题。申请的堆内存并没有用完,但是用户申请的内存的时候却没有合适的空间提供。这样会遍历整个链表,还会继续向操作系统申请内存。这就能解释我一开始描述的问题,申请一块内存变成了慢语句。
申请内存变成了慢语句,解决方法就是使用Pool临时对象池
1 | package main |
Output:
1 | without pool 20 s |
新键 Pool 需要提供一个 New 方法,目的是当获取不到临时对象时自动创建一个(不会主动加入到 Pool 中),Get 和 Put 方法都很好理解。
深入了解过 Go 的同学应该知道,Go 的重要组成结构为 M、P、G。Pool 实际上会为每一个操作它的 goroutine 相关联的 P 都生成一个本地池。如果从本地池 Get 对象的时候,本地池没有,则会从其它的 P 本地池获取。因此,Pool 的一个特点就是:可以把由其中的对象值产生的存储压力进行分摊。
它有着以下特点:
- Pool的目的是缓存已分配但未使用的项目以备后用
- 多协程并发安全
- 缓存在Pool里的item会没有任何通知情况下随时被移除,以缓解GC压力
- 池提供了一种方法来缓解跨多个客户端的分配开销。
- 不是所有场景都适合用Pool,如果释放链表是某个对象的一部分,并由这个对象维护,而这个对象只由一个客户端使用,在这个客户端工作完成后释放链表,那么用Pool实现这个释放链表是不合适的。
官方对Pool的目的描述:
Pool设计用意是在全局变量里维护的释放链表,尤其是被多个 goroutine 同时访问的全局变量。使用Pool代替自己写的释放链表,可以让程序运行的时候,在恰当的场景下从池里重用某项值。sync.Pool一种合适的方法是,为临时缓冲区创建一个池,多个客户端使用这个缓冲区来共享全局资源。另一方面,如果释放链表是某个对象的一部分,并由这个对象维护,而这个对象只由一个客户端使用,在这个客户端工作完成后释放链表,那么用Pool实现这个释放链表是不合适的。
那么 Pool 都适用于什么场景呢?从它的特点来说,适用与无状态的对象的复用,而不适用与如连接池之类的。在 fmt 包中有一个很好的使用池的例子,它维护一个动态大小的临时输出缓冲区。
1 | package main |
总结:
- 当每个对象的内存小于一定量的时候,不使用pool的性能秒杀使用pool;当内存处于某个量的时候,不使用pool和使用pool性能相当;当内存大于某个量的时候,使用pool的优势就显现出来了
- 不使用pool,那么对象占用内存越大,性能下降越厉害;使用pool,无论对象占用内存大还是小,性能都保持不变。可以看到pool有点像飞机,虽然起步比跑车慢,但后劲十足。
即:pool适合占用内存大且并发量大的场景。当内存小并发量少的时候,使用pool适得其反
正确用法
在Put之前重置,在Get之后重置
1 | package main |
实例:gin的context通过pool来get和put,也就是使用了sync.Pool进行维护
1 | // Conforms to the http.Handler interface. |
Once 执行一次
使用 sync.Once 对象可以使得函数多次调用只执行一次。其结构为:
1 | type Once struct { |
用 done 来记录执行次数,用 m 来保证保证仅被执行一次。只有一个 Do 方法,调用执行。
1 | package main |
Strings包
Strings包
strings包中提供了各种操作字符串的方法,下面详细介绍
Index操作
index
查找字串在目标串中出现的位置
1 | package main |
LastIndex和LastIndexAny
查找最后一次出现的字符,以及任意一个最后出现的字符
1 | package main |
trim操作
trim
返回除去包含给定字符的前缀和后缀的字符串(如果给定包含多个字符,则都会去除)
1 | package main |
trimLeft和trimRight
只去除左边或者右边的
1 | package main |
TrimPrefixx和Trimsuffix
去除包含前缀或者后缀的
1 | package main |
TrimSpace
去除前后空格
1 | package main |
replace操作
replace
替换目标字符串中第一个符合模式串的,可以指定替换多少个
1 | package main |
ReplaceAll
替换所有字符,底层调用的是Replace,其中最后一个参数是-1,表示替换所有
1 | package main |
大小写转换操作
ToUpper 和ToLower
1 | package main |
Repeat
重复字符串之后输出,可以指定重复的次数
1 | package main |
Join操作
在字符串数组的每个空格之处添加指定的字符串
1 | package main |
Split操作
Split
按给定字符切分字符串且删除给定字符
1 | package main |
SplitN
可以指定索引位置
1 | package main |
SplitAfter和SplitAfterN
按给定字符切分,删除的是字符后面的字符,同时也可以指定索引
1 | package main |
net包
net包
网络模型
在总结 net 包之前,还需要温习模糊的网络模型知识。下图是大学课本上的网络模型图:
模型图中可以看到,OSI 的七层模型,每一层实现的是与对端相应层的通信接口。但是实际应用中,我们把会话层、表示层、应用层统称为应用层。因此,就变成了TCP/IP 的五层模型。 其中网络层包含了 ip,arp,icmp 等协议,传输层包含了 TCP, UDP 等协议,应用层,比如 SMTP,DNS,HTTP 等协议。
在 net 包中,主要涉及网络层和传输层的协议。支持如下:
网络层:
- ICMP
- IGMP
- IVP6-ICMP
传输层:
- TCP
- UDP
Socket 编程
在讲代码结构前,还需要回忆(学习)几个 Socket 编程(套接字编程)的知识点。
- 在 Linux 上一切皆文件。所以各端口的读写服务可以认为是读取/写入文件, 一般使用文件描述符 fd (file descriptor) 表示。在Windows上,各端口的读写服务是一个通信链的句柄操作,通过句柄实现网络发出请求和读取数据。在 go 中为了统一,采用 linux 的 fd 代表一个链接节点。
- TCP 是面向连接的、可靠的流协议,可以理解为不断从文件中读取数据(STREAM)。UDP 是无链接的、面向报文的协议,是无序,不可靠的(DGRAM)(目前很多可靠的协议都是基于UDP 开发的)。
- UNIXDomain Socket 是一种 进程间通信的协议,之前仅在*nix上使用,17年 17063 版本后支持了该协议。虽然是一个 IPC 协议,但是在实现上是基于套接字 (socket) 实现的。因此,UNIXDomain Socket 也放在了net 包中。
- unixDomain Socket 也可以选择采用比特流的方式,或者无序的,不可靠的通讯方式,有序数据包的方式(SEQPACKET, Linux 2.6 内核才支持)
代码结构
下面我们看看 net 包中一些接口,以及一些接口的实现。
从图中可以看出,基于 TCP、UDP、IP、Unix (Stream 方式)的链接抽象出来都是 Conn 接口。基于包传递的 UDP、IP、UnixConn (DGRAM 包方式) 都实现了 PacketConn 接口。对于面向流的监听器,比如: TCPListener、 UnixListener 都实现了 Listener 接口。
整体上可以看出,net 包对网络链接是基于我们复习的网络知识实现的。对于代码的底层实现,也是比较简单的。正对不同的平台,调用不同平台套接字的系统调用即可。直观上看,对于不同的链接,我们都是可以通过Conn 的接口来做网络io的交互。
如何使用
在了解了包的构成后,我们基于不同的网络协议分两类来学习如何调用网络包提供的方法。
基于流的协议
基于流的协议,net 包中支持了常见的 TCP,Unix (Stream 方式) 两种。基于流的协议需要先于对端建立链接,然后再发送消息。下面是 Unix 套接字编程的一个流程:
首先,服务端需要绑定并监听端口,然后等待客户端与其建立链接,通过 Accept 接收到客户端的连接后,开始读写消息。最后,当服务端收到EOF标识后,关闭链接即可。 HTTP, SMTP 等应用层协议都是使用的 TCP 传输层协议。
基于包的协议
基于包的协议,net 包中支持了常见的 UDP,Unix (DGRAM 包方式,PacketConn 方式),Ip (网络层协议,支持了icmp, igmp) 几种。基于包的协议在bind 端口后,无需建立连接,是一种即发即收的模式。
基于包的协议,例如基于UDP 的 DNS解析, 文件传输(TFTP协议)等协议,在网络层应该都是基于包的协议。 下面是基于包请求的Server 端和Client端:
可以看到,在Socket 编程里, 基于包的协议是不需要 Listen 和 Accept 的。在 net 包中,使用ListenPacket,实际上仅是构造了一个UDP连接,做了端口绑定而已。端口绑定后,Server 端开始阻塞读取包数据,之后二者开始通信。由于基于包协议,因此,我们也可以采用PacketConn 接口(看第一个实现接口的图)构造UDP包。
一个简单的例子
下面,我们构造一个简单的 Redis Server (支持多线程),实现了支持Redis协议的简易Key-Value操作(可以使用Redis-cli直接验证):
1 | package main |
上述代码没有任何的异常处理,仅作为网络连接的一个简单例子。
从代码中可以看出,我们的数据流式的网络协议,在建立连接后,可以和文件IO服务一样,可以任意的读写操作。
正常情况下,流处理的请求,都会开启一个协程来做连接处理,主协程仅用来接收连接请求。(基于包的网络协议则可以不用开启协程处理)
几个demo
模拟请求网易
1 | package main; |
创建服务器
1 | package main; |
TCP实现
server端
1 | package main |
client端
1 | package main |
UDP实现
server端
1 | package main |
client端
1 | package main |
总结
- 基于 Conn 的消息都是有三种过期时间,这其实是在底层epoll_wait中设置的超时时间。 Deadline 设置了Dail中建立连接的超时时间, ReadDeadline 是 Read 操作的超时时间, WriteDeadline 为 Write 操作的超时时间。
- net 包作为基础包,基于net开发应用层协议比较多,例如 net/http, net/rpc/smtp 等。
- 网络的io操作底层是基于epoll来实现的, unixDomain 基于文件来实现的。
- net 包实现的套接字编程仅是我们日常生活中用的比较多的一些方法,还有很多未实现的配置待我们去探索。
- 网络模型比较简单,实际用起来,还是需要分门别类的。
io包
io包
io.Write接口
Write接口介绍
io.Writer
表示一个编写器,它从缓冲区读取数据,并将数据写入目标资源。对于要用作编写器的类型,必须实现 io.Writer
接口的唯一一个方法 Write(p []byte)
,只要实现了 Write(p []byte)
,那它就是一个编写器。Write()
方法有两个返回值,一个是写入到目标资源的字节数,一个是发生错误时的错误。
1 | type Writer interface { |
Writer接口使用
标准库提供了许多已经实现了 io.Writer
的类型。
简单的例子
将数据写入内存缓冲区
1 | package main |
实现自己的writer
1 | package main |
io.Reader接口
Reader接口介绍
io.Reader
表示一个读取器,它将数据从某个资源读取到传输缓冲区。在缓冲区中,数据可以被流式传输和使用。对于要用作读取器的类型,它必须实现 io.Reader
接口的唯一一个方法 Read(p []byte)
。换句话说,只要实现了 Read(p []byte)
,那它就是一个读取器
1 | type Reader interface { |
Read()
方法有两个返回值,一个是读取到的字节数,一个是发生错误时的错误。同时,如果资源内容已全部读取完毕,应该返回 io.EOF
错误。
Reader接口使用
利用 Reader
可以很容易地进行流式数据传输。Reader
方法内部是被循环调用的,每次迭代,它会从数据源读取一块数据放入缓冲区 p
(即 Read 的参数 p)中,直到返回 io.EOF
错误时停止。
简单的例子
按字节读取
1 | package main |
自己实现reader
1 | package main |
组合多个 Reader
组合多个 Reader,目的是重用和屏蔽下层实现的复杂度。标准库已经实现了许多 Reader。使用一个 Reader
作为另一个 Reader
的实现是一种常见的用法。这样做可以让一个 Reader
重用另一个 Reader
的逻辑,下面展示通过更新 alphaReader
以接受 io.Reader
作为其来源
1 | package main |
io包中其他有用的方法
标准输入输出
os
包有三个可用变量 os.Stdout
,os.Stdin
和 os.Stderr
,它们的类型为 os.File
,分别代表 系统标准输入
,系统标准输出
和 系统标准错误
的文件句柄。例如,下面的代码直接打印到标准输出:
1 | package main |
os.File
类型 os.File
表示本地系统上的文件。它实现了 io.Reader
和 io.Writer
,因此可以在任何 io 上下文中使用。
例如,下面的例子展示如何将连续的字符串切片直接写入文件
1 | package main |
同时,io.File
也可以用作读取器来从本地文件系统读取文件的内容。
例如,下面的例子展示了如何读取文件并打印其内容:
1 | package main |
io.Copy
io.Copy()
可以轻松地将数据从一个 Reader 拷贝到另一个 Writer。它抽象出 for
循环模式并正确处理 io.EOF
和 字节计数。
下面是我们之前实现的简化版本:
1 | package main |
io.WriteString
此函数让我们方便地将字符串类型写入一个 Writer:
1 | package main |
io.PipeWriter&io.PipeReader
类型 io.PipeWriter
和 io.PipeReader
在内存管道中模拟 io 操作。数据被写入管道的一端,并使用单独的 goroutine 在管道的另一端读取。下面使用 io.Pipe()
创建管道的 reader 和 writer,然后将数据从 proverbs
缓冲区复制到io.Stdout
1 | package main |
bufio包
标准库中 bufio
包支持 缓冲区 io 操作,可以轻松处理文本内容。例如,以下程序逐行读取文件的内容,并以值 '\n'
分隔
1 | package main |
ioutil包
io
包下面的一个子包 utilio
封装了一些非常方便的功能。例如,下面使用函数 ReadFile
将文件内容加载到 []byte
中。
1 | package main |