Golang初体验 | 青训营笔记

85 阅读6分钟

这是我参加「第五届青训营 」伴学笔记创作活动的第 1 天

go语言协程与线程对比

  1. 线程的上下文数据保存在内核态的内存空间,线程切换操作最终在内核层完成,应用层需要调用内核层提供的 syscall 底层函数。调度策略由OS实现,我们无法干预。 协程的数据一般存储在线程提供的用户态内存空间,应用层使用代码进行简单的现场保存和恢复即可。调度策略由应用层代码定义,即可被高度自定义实现。
  2. 多个协程可交由某一个线程去执行,复用这个线程。一个线程只能某一时刻只能跑一个协程,但是协程可以像线程一样切换执行,它们的调度不是线程的切换,而是纯应用态的协程调度,开销非常低。因此,同一个线程内的协程之间,不存在读写冲突,提高了执行效率。

进程、线程、协程的演变

引入进程,是为了压榨CPU的性能,让CPU一直处于运行状态而不被IO阻塞。当CPU阻塞时,切换别的进程去执行。因此,为了对不同的进程进行隔离,给各个进程划分独立的地址空间。

进程和线程在 Linux 中没有本质区别,他们最大的不同就是进程有自己独立的内存空间,而线程(同进程中)是共享内存空间。

而进程切换 CPU 时需要干两件事:使用即将执行的进程地址空间(切换页目录,非常耗时)、切换执行上下文。

而使用线程,共享同一个进程的地址空间,切换线程的时候就不必再切换页目录了,切换的开销也就更低。

对于Web服务器来说,吞吐量是性能的重要指标。

如何提高吞吐量呢?

  1. 最开始是使用多线程技术,来一个请求就开辟一个新线程。但是,由于CPU数量有限,这样只适合连接数少的场景,并且切换成本很高。

  2. 为了防止线程频繁创建销毁,就引入了线程池。Tomcat就是这样做的。但是这样做仍有缺点,只适用于短连接的场景,在阻塞模式下,一个线程只能处理一个socket连接。所以http早期就被设计成了短连接,就是为了减少对线程资源的占用。

  3. 使用NIO(非阻塞IO)的开发模型,通过 IO多路复用让进程或线程不阻塞,省去上下文切换的开销。 我接触过的典型实现就是NIONetty。在NIO中,selector 对象配合一个线程来管理多个 channel,获取这些 channel 上发生的事件,这些 channel 工作在非阻塞模式下,不会让线程吊死在一个 channel 上。适合连接数特别多,但流量低的场景(low traffic)。调用 selector 的 select() 会阻塞(当前所在线程挂起)直到 channel 发生了读写就绪事件,这些事件发生,select 方法就会返回这些事件交给 thread 来处理。 netty是reactor模式加线程池。但是,类库也很庞大也比较复杂,要写很多回调,可读性比较差。

协程就是为了解决线程粒度还不够细的问题。举个例子,在网络服务中,调用read函数读取数据,如果socket缓冲区没有数据,当前线程就会阻塞一直到缓冲区可读才行。注意,整个线程会被阻塞,而并发性能自然会受到影响。

如果能把线程更细粒度区分为很多子任务,线程在多个子任务之间交替执行。比如在子任务A里面调用 read 函数,如果socket不可读,那么子任务A阻塞,让出执行权,线程转而去执行其他的子任务。 当可读条件满足后,线程又唤醒子任务A,从上次read阻塞的地方恢复继续执行。

可以看到,线程并没有阻塞,而是转而去执行其他任务。这对并发就进一步提高了。

另外,这里子任务简单来说就是一个函数罢了,要封装这么一个子任务也很简单,把当前函数的栈空间寄存器状态保存下来即可。

而这个子任务,其实就是协程的概念。由于它只用一些寄存器状态就可以描述,所以其实协程占用的资源非常少,要实现上万的协程是非常容易的。

引用 : www.zhihu.com/question/49…

  1. 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读取数据

微信截图_20230114215713.png 上下两个代码块作用相同

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可以计算单元测试覆盖率

使用mock来移除外部依赖(DB等)