这是我参加「第五届青训营 」伴学笔记创作活动的第 1 天
go语言协程与线程对比
线程的上下文数据保存在内核态的内存空间,线程切换操作最终在内核层完成,应用层需要调用内核层提供的 syscall 底层函数。调度策略由OS实现,我们无法干预。协程的数据一般存储在线程提供的用户态内存空间,应用层使用代码进行简单的现场保存和恢复即可。调度策略由应用层代码定义,即可被高度自定义实现。- 多个协程可交由某一个线程去执行,复用这个线程。一个线程只能某一时刻只能跑一个协程,但是协程可以像线程一样切换执行,它们的调度不是线程的切换,而是
纯应用态的协程调度,开销非常低。因此,同一个线程内的协程之间,不存在读写冲突,提高了执行效率。
进程、线程、协程的演变
引入进程,是为了压榨CPU的性能,让CPU一直处于运行状态而不被IO阻塞。当CPU阻塞时,切换别的进程去执行。因此,为了对不同的进程进行隔离,给各个进程划分独立的地址空间。
进程和线程在 Linux 中没有本质区别,他们最大的不同就是进程有自己独立的内存空间,而线程(同进程中)是共享内存空间。
而进程切换 CPU 时需要干两件事:使用即将执行的进程地址空间(切换页目录,非常耗时)、切换执行上下文。
而使用线程,共享同一个进程的地址空间,切换线程的时候就不必再切换页目录了,切换的开销也就更低。
对于Web服务器来说,吞吐量是性能的重要指标。
如何提高吞吐量呢?
-
最开始是使用多线程技术,来一个请求就开辟一个新线程。但是,由于CPU数量有限,这样只适合连接数少的场景,并且切换成本很高。
-
为了防止线程频繁创建销毁,就引入了线程池。Tomcat就是这样做的。但是这样做仍有缺点,只适用于短连接的场景,在阻塞模式下,一个线程只能处理一个socket连接。所以http早期就被设计成了短连接,就是为了减少对线程资源的占用。
-
使用NIO(非阻塞IO)的开发模型,通过 IO多路复用让进程或线程不阻塞,省去上下文切换的开销。 我接触过的典型实现就是
NIO和Netty。在NIO中,selector 对象配合一个线程来管理多个 channel,获取这些 channel 上发生的事件,这些 channel 工作在非阻塞模式下,不会让线程吊死在一个 channel 上。适合连接数特别多,但流量低的场景(low traffic)。调用 selector 的 select() 会阻塞(当前所在线程挂起)直到 channel 发生了读写就绪事件,这些事件发生,select 方法就会返回这些事件交给 thread 来处理。 netty是reactor模式加线程池。但是,类库也很庞大也比较复杂,要写很多回调,可读性比较差。
协程就是为了解决线程粒度还不够细的问题。举个例子,在网络服务中,调用read函数读取数据,如果socket缓冲区没有数据,当前线程就会阻塞一直到缓冲区可读才行。注意,整个线程会被阻塞,而并发性能自然会受到影响。
如果能把线程更细粒度区分为很多子任务,线程在多个子任务之间交替执行。比如在子任务A里面调用 read 函数,如果socket不可读,那么子任务A阻塞,让出执行权,线程转而去执行其他的子任务。 当可读条件满足后,线程又唤醒子任务A,从上次read阻塞的地方恢复继续执行。
可以看到,线程并没有阻塞,而是转而去执行其他任务。这对并发就进一步提高了。
另外,这里子任务简单来说就是一个函数罢了,要封装这么一个子任务也很简单,把当前函数的栈空间、寄存器状态保存下来即可。
而这个子任务,其实就是协程的概念。由于它只用一些寄存器状态就可以描述,所以其实协程占用的资源非常少,要实现上万的协程是非常容易的。
- reactor模式里要写很多回调,但是有了协程就可以通过select和go等几个简单的关键字,就可以实现同样的效果(相当于语言帮我们封装好了)。
Channel
通过chan关键字声明Channel,并且需要在后面指明Channel传输数据的类型,并且可以Channel作为函数参数时可以指定当前方法中Channel的传输的方向(默认是双向的Channel);
// chan<- int it's a channel to only send data
// <-chan int it's a channel to only receive data
func send(ch chan<- string, message string) {
fmt.Printf("Sending: %#v\n", message)
ch <- message
}
ch := make(chan string)
默认情况下 channel 是无缓冲区的。 这意味着只有存在接收数据的操作时,它才接受发送数据的操作。 否则,程序将永久被阻止等待。
ch := make(chan string, 10)
设置缓冲区大小为10,每次向有缓冲区的channel 发送数据时,都会将元素添加到队列中。 然后,接收操作将从队列中获取该元素并产出。 当 channel 已满时,任何发送操作都将等待,直到有空间保存数据。 相反,如果 channel 是空的且存在读取操作,程序则会被阻止,直到有数据要读取。
package main
import (
"fmt"
)
func send(ch chan string, message string) {
ch <- message
}
func main() {
size := 2
ch := make(chan string, size)
send(ch, "one")
send(ch, "two")
send(ch, "three")
send(ch, "four")
fmt.Println("All data sent to the channel ...")
for i := 0; i < size; i++ {
fmt.Println(<-ch)
}
fmt.Println("Done!")
}
设置size=4时,程序会按照预期运行。但是当size=2时,会报错。原因就是Channel的缓冲区太小,send一直被阻塞。
channel 与 goroutine 有着紧密的联系。 如果没有另一个 goroutine 从 channel 接收数据,则整个程序可能会永久处于阻塞状态。
使用哪种Channel,取决于希望 goroutine 之间的通信如何进行。 无缓冲 channel 同步通信。 它们保证每次发送数据时,程序都会被阻止,直到有人从 channel 中读取数据。
相反,有缓冲 channel 将发送和接收操作解耦。 它们不会阻止程序,但你必须小心使用,因为可能最终会导致死锁(如前文所述)。 使用无缓冲 channel 时,可以控制可并发运行的 goroutine 的数量。
Channel多路复用
由于从Channel中读取数据时,可能被阻塞,所以我们可以使用多路复用技术,选取出可以读取的Channel。Go中提供的select关键字可以非常轻松的完成这件事。
select 语句的工作方式类似于 switch 语句,但它适用于 channel。 它会阻止程序的执行,直到它收到要处理的事件。 如果它收到多个事件,则会随机选择一个。select 语句的一个重要方面是,它在处理事件后完成执行。 如果要等待更多事件发生,则可能需要使用循环。
package main
import (
"fmt"
"time"
)
func process(ch chan string) {
time.Sleep(3 * time.Second)
ch <- "Done processing!"
}
func replicate(ch chan string) {
time.Sleep(1 * time.Second)
ch <- "Done replicating!"
}
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go process(ch1)
go replicate(ch2)
for i := 0; i < 2; i++ {
select {
case process := <-ch1:
fmt.Println(process)
case replicate := <-ch2:
fmt.Println(replicate)
}
}
}
可以使用range来迭代不断从Channel读取数据
上下两个代码块作用相同
Sync
sync包下提供了一些锁。比如sync.mutex。应该是重入锁吧。和java里的差不多,不再赘述了。
依赖管理
GOPATH
GO Vendor
Go Module
go mod init 生成gomod文件
go mod download 下载依赖到本地缓存
go mod tidy 整理依赖性,删除不需要的依赖,下载缺失的依赖
单元测试
- 所有测试文件以
_test.go结尾 - 测试的目标函数命名为
TestXxx(t *testing.T) - 初始化逻辑放到
Test.main(m *testing.M)函数中 - 使用go test [flags] [package] 自动运行测试 --cover可以计算单元测试覆盖率