Java学渣学习GO语言的工程实践课后作业 | 青训营笔记

101 阅读17分钟

Java学渣学习go的第二篇笔记 | 青训营笔记

前言

这是我参加参加字节跳动第六届青训营所做的第二篇笔记,希望可以帮助初学go的我更平滑的完成蜕变吧

go语言进阶

Goroutine

与其他语言的协程实现相比,Go语言的Goroutine更加轻量级且高效。它在用户态下执行,协程栈的占用非常小,仅有几KB的空间,这使得系统资源的利用更加高效。

与其他语言的协程相比,Goroutine的使用也更加简化和方便。在Go语言中,我们只需使用一个简单的关键字"go"就可以创建一个Goroutine,而不需要像其他语言那样进行繁琐的初始化和管理。这使得开发人员可以更加专注于编写业务逻辑,而不必担心协程的细节。

需要注意的是,Goroutine是有栈协程,这意味着每个Goroutine都拥有自己的栈空间。与之相对的是无栈协程(如Kotlin协程),它们共享相同的栈空间。有栈协程的优势在于可以在不同的Goroutine之间进行更细粒度的隔离,每个Goroutine可以独立地管理自己的栈空间,避免了潜在的竞争和冲突。

Goroutine作为Go语言并发编程的核心机制,极大地简化了并发编程的复杂性。通过轻量级的Goroutine和简洁的"go"关键字,开发人员可以更加方便地实现并发任务的调度和管理。而有栈协程的设计使得Goroutine之间可以独立运行,提高了并发程序的安全性和可靠性。总之,Goroutine是Go语言的一大亮点,使得并发编程更加高效和易于实现。

package main
 
import (
    "fmt"
    "time"
)
 
func say(s string) {
    for i := 0; i < 5; i++ {
        time.Sleep(100 * time.Millisecond)
        fmt.Println(s)
    }
}
 
func main() {
    go say("world")
    say("hello")
    time.Sleep(time.Second)
}
复制代码

首先,需要注意的是,即使在代码中没有使用go关键字,程序仍会在一个协程中运行,这个协程就是main函数本身。

在上面的代码中,我们定义了一个名为say的函数,它接受一个字符串作为参数,然后每隔100毫秒打印一次传入的字符串,重复这个过程5次。接着,在main函数中,我们调用了两次say函数,并传入了不同的参数"hello"和"world"。最后,我们调用了time.Sleep()函数来让当前的协程(也就是main函数的主协程)休眠1秒。需要注意的是,第一个say函数的前面标有go关键字,这意味着它将在一个新的Goroutine协程中运行。

在Go语言中,每个程序都会在一个默认的协程中运行,即使我们没有显式地使用go关键字来创建新的协程。在main函数中,我们可以通过使用go关键字来创建新的Goroutine,这样可以并发地执行一些任务。通过使用Goroutine,我们可以充分利用CPU的多核处理能力,提高程序的执行效率。同时,我们还可以使用通道(Channel)来实现Goroutine之间的通信和同步,进一步增强程序的并发性能和可靠性。总之,Goroutine是Go语言并发编程的核心概念,通过它我们可以轻松地实现并发执行和协作处理任务。

hello
world
world
hello
hello
world
world
hello
hello
复制代码

hello 和 world 两个字符串在以未知的顺序交替打印

协程在处理并发任务时有一个潜在的陷阱,就是并发并不意味着真正的并行执行。尽管协程可以实现并发处理多个任务,但这些任务实际上仍然是在同一个线程中运行,而无法同时在多个CPU核心上进行真正的并行执行。

在上面的代码中,我们使用了goroutine来创建了两个协程来并发执行say函数。然而,这些协程实际上是在同一个线程中运行的,它们的执行是通过线程的时间片轮转进行切换的。因此,虽然我们看起来可以同时执行两个任务,但它们实际上是以交替的方式进行执行的。

这种并发而非真正的并行执行可能会导致一些问题。首先,如果任务之间存在共享的资源,例如全局变量,可能会发生竞争条件,导致数据不一致或错误的结果。其次,由于协程只是在同一个线程中切换执行,并不能充分利用多个CPU核心的计算能力,无法实现真正的并行加速。

为了解决这个问题,我们可以使用并行编程技术,例如使用多线程来实现真正的并行执行,或者使用通道来进行协程之间的同步和通信,避免竞争条件的发生。另外,还可以使用互斥锁(Mutex)或原子操作等机制来保护共享资源的访问,确保数据的一致性和正确性。

所以说,尽管协程可以实现并发处理多个任务,但需要注意并发并不等于真正的并行执行。在使用协程进行并发编程时,需要考虑共享资源的访问控制、同步和通信的机制,以及真正的并行加速的需求。

world
world
world
world
world
hello
hello
hello
hello
hello
复制代码

在上述代码的main函数中,time.Sleep(time.Second)函数被用来暂停(休眠)主协程(也就是主线程)1秒钟的时间。这样做的目的是为了确保在启动两个goroutine后,给它们足够的时间来执行完毕。

在注释掉time.Sleep函数的情况下,主协程将立即执行完所有的代码,然后退出程序。由于在处理goroutine的同时,主协程也会继续执行,因此它可能在goroutine还没有完成之前就结束了。这可能导致goroutine没有足够的时间来完成任务,从而导致这些任务无法正常执行或输出结果。

通过添加time.Sleep函数,主协程将被暂停一段时间,以确保给其他goroutine足够的执行时间。这样可以保证在主协程退出之前,其他goroutine有足够的时间完成它们的任务。这对于需要等待goroutine完成的情况非常重要,以确保程序的正确执行和输出结果的准确性。

需要注意的是,time.Sleep函数是一个阻塞调用,它会使当前的协程休眠指定的时间。在实际开发中,如果需要更加灵活的控制,可以考虑使用通道(channel)或者等待组(wait group)等机制来实现协程之间的同步和等待。

总结起来,通过在主协程中使用time.Sleep函数来暂停一段时间,保证了其他goroutine有足够的执行时间,从而确保了并发任务的正确性和完整性。

hello
hello
hello
hello
hello
复制代码

在使用协程处理并发任务时,确保所有子协程都执行完毕后再结束主协程是非常重要的。在上述代码中,由于注释掉了time.Sleep(time.Second),主协程在执行完say("hello")后立即退出,没有等待say("world")的执行。

为了解决这个问题,可以使用Go语言标准库中的sync.WaitGroup来进行协程之间的同步和等待。

WaitGroup是一个计数器,用来统计还未完成的协程数量。在主协程中,我们可以通过调用WaitGroup的Add方法,将待执行的协程数量加一。在每个子协程的结尾,通过调用WaitGroup的Done方法,将计数器减一。

然后在主协程中,通过调用WaitGroup的Wait方法,会被阻塞,直到计数器归零,也就是所有的子协程都执行完毕,才会继续执行后续的代码。

因此,使用WaitGroup可以保证主协程的执行在所有子协程之后,确保所有子协程都有足够的时间完成任务,从而避免了程序提前退出的问题。

即通过使用WaitGroup可以实现主协程等待子协程完成的功能,确保在并发任务中的正确执行顺序和结果输出。

WaitGroup

func hello(i int) {
    println("hello goroutine : " + fmt.Sprint(i))
}
 
func HelloGoRoutine() {
    for i := 0; i < 5; i++ {
        go func(j int) {
            hello(j)
        }(i)
    }
    time.Sleep(time.Second)
}
 
func main() {
    HelloGoRoutine()
}
复制代码

上述代码的作用是通过goroutine并发地执行hello函数,打印出"hello goroutine : "加上不同的索引值,实现了多个goroutine的并发执行和输出结果的展示。

func(j int) {
  hello(j)
}
复制代码

上述代码是一个匿名函数的定义。这个匿名函数使用闭包的方式捕获外部的变量j,并将j作为参数传递给hello函数。

匿名函数的作用是在每次循环迭代过程中启动一个goroutine,并执行hello函数。通过传递不同的参数j给hello函数,实现了对hello函数的多次调用。

在每次循环迭代过程中,匿名函数会创建一个独立的goroutine,并将当前迭代的索引值j作为参数传递给hello函数。这样,在并发执行的goroutine中,每个goroutine都会调用hello函数并打印出"hello goroutine : "加上对应的索引值。

这种方式可以实现并发地执行多个goroutine,并传递不同的参数给被调用的函数。通过闭包的特性,可以确保每个goroutine都能够独立地访问到对应的索引值,避免了竞态条件的问题。

总结起来,上述代码的作用是定义了一个匿名函数,通过闭包的方式捕获外部变量并传递给hello函数,实现了多次goroutine的并发执行和输出结果的展示。

为了保证子协程执行完毕后主协程才会退出,改造 HelloGoRoutine 函数:

func HelloGoRoutine() {
    var wg sync.WaitGroup
    wg.Add(5)
    for i := 0; i < 5; i++ {
        go func(j int) {
            defer wg.Done()
            hello(j)
        }(i)
    }
    wg.Wait()
}
复制代码

time.Sleep(time.Second) 被移除,取而代之的是 wg.Wait(),此外,函数还增加了许多其他调用。 这些修改引入了 WaitGroup,用于等待并发执行的多个 goroutine 完成。Add 方法增加了等待的 goroutine 数量,Done 方法在每个 goroutine 完成时调用,Wait 方法阻塞主协程直到所有的 goroutine 完成。这样可以实现对并发执行的 goroutine 的控制和等待。

并发安全 Lock

var x int64
 
func addWithoutLock() {
    for i := 0; i < 2000 ; i++ {
        x += 1
    }
}
 
func main(){
    x = 0
    for i := 0; i < 5; i++ {
        go addWithoutLock()
    }
    time.Sleep(time.Second)
    println("WithoutLock:",x)
}
复制代码

以上是个经典的并发安全问题案例,本应输出10000,但是结果却是:

WithoutLock: 9446
复制代码

在上述的例子中,如果不对变量的并发访问进行保护,多个协程同时对变量进行修改就可能导致竞态条件。为了避免这种情况,可以使用同步机制来保证并发安全,比如互斥锁、读写锁、信号量等。这些机制可以保证在某个协程持有锁的时候,其他协程无法访问共享资源,以确保并发操作的正确性。

简而言之,为了保证并发安全,需要使用合适的同步机制来保护共享资源的并发访问,避免竞态条件的发生。这样可以确保并发操作的正确性和一致性。

为了解决该问题,我们可以引入并发锁 sync.Mutex

var (
    x    int64
    lock sync.Mutex
)
 
func addWithLock() {
    for i := 0; i < 2000; i++ {
        lock.Lock()
        x += 1
        lock.Unlock()
    }
}
 
func main() {
    x = 0
    for i := 0; i < 5; i++ {
        go addWithLock()
    }
    time.Sleep(time.Second)
    println("WithLock:", x)
}
复制代码

在并发编程中,锁是一种常见的同步机制。锁的原理是,当第一次调用 Lock 方法时,什么都不会发生,但当第二次调用 Lock 方法时,该调用会立即阻塞协程或线程,直到有其他程序调用 Unlock 方法来释放锁。

使用锁时需要非常慎重,因为锁本质上将并行的程序串行化,可能会导致性能降低。因此,应该只在需要保证并发安全的代码中使用锁。

除了使用锁来保证并发安全,对于单个值的修改而不是一段代码的执行,可以使用 sync/atomic 包中的原子操作来实现。原子操作能够保证对变量的操作是不可分割的,不会被其他协程或线程的干扰,从而避免竞态条件的发生。在其他编程语言中,也有类似的原子操作机制,比如 Java 的 AtomicInteger 等。

Channel

在协程之间进行数据传输和通信是并发编程中重要的一部分。在Go语言中,可以使用内置的通道(Channel)来实现协程之间的安全数据传输和通信。

通道是一种特殊的数据结构,可以在不同的协程之间传递值。它提供了一种通过发送和接收操作进行同步的方式。通过使用通道,协程可以安全地发送数据到通道,其他协程可以从通道中接收到这些数据并进行处理。

相比于通过共享内存实现数据通信,通道的使用更加安全可靠。通过通道,协程之间的数据传输不依赖于共享内存,从而避免了竞态条件和数据访问冲突的问题。

在Go语言中,我们可以使用make函数创建一个通道,并通过<-操作符进行数据的发送和接收。通道可以具有不同的类型,用于限制发送和接收的数据类型。

例如,下面的代码演示了一个简单的使用通道进行协程间通信的例子:

package main

import "fmt"

func main() {
    // 创建一个字符串类型的通道
    ch := make(chan string)

    // 启动一个协程
    go func() {
        // 向通道发送数据
        ch <- "Hello, World!"
    }()

    // 从通道接收数据,并打印
    msg := <-ch
    fmt.Println(msg)
}
复制代码

在上述代码中,我们创建了一个字符串类型的通道ch,然后启动一个匿名协程,向通道发送了一条字符串数据。主协程通过从通道接收数据,并打印出来。

通过使用通道,我们可以实现协程之间的安全数据传输和通信,避免了共享内存可能带来的一些问题。同时,通道也提供了一种同步的方式,可确保只有当数据可用时才进行接收操作。

总而言之,使用通道能够提供安全可靠的协程间数据传输和通信的机制,相比于共享内存,通道的使用更为推荐。

go依赖管理

依赖管理是为了解决项目引用第三方库的问题。在Go语言中,通过依赖管理可以方便地引入和管理项目需要使用的库。

需要依赖管理的原因是,一个项目通常不仅仅使用标准库,还需要引用其他开发者或组织提供的库。这些库可能存在于不同的存储库和版本中,而且可能有自己的依赖关系。

Go在发展过程中经历了不同的依赖管理方式。最初的方式是通过设置GOPATH环境变量,并将依赖库的源码放置在GOPATH的src文件夹中。然后引入了Go Vendor的方式,通过将依赖库的副本放置在项目的vendor文件夹中,解决了版本冲突的问题。但是这种方式依然存在依赖冲突的问题。

最终,Go引入了Go Module的依赖管理方式。Go Module通过在项目路径中创建go.mod文件来管理项目的依赖关系。在go.mod文件中声明项目所需的依赖库及其版本范围。同时,还有一个go.sum文件用于记录实际使用的依赖版本。

通过使用Go Module,我们不需要手动编辑配置文件来指定依赖,可以使用go get和go mod命令方便地添加和移除依赖。Go会根据go.mod和go.sum文件中的定义自动下载和管理依赖。

同时,为了提高依赖下载的效率,Go引入了Proxy系统,允许设置一个代理服务器来提供依赖下载服务。通过指定GOPROXY环境变量,可以使用各种代理服务器进行依赖下载,包括国内的代理服务器。

总的来说,Go的依赖管理是通过Go Module来实现的,它提供了简单和灵活的方式来管理项目的依赖关系,并且能够自动解决依赖冲突的问题。通过使用go get和go mod命令,可以方便地添加和移除项目中的依赖。同时,使用代理服务器可以提高依赖下载的效率,尤其对于国内的开发者来说非常有帮助。

Go 与单元测试

单元测试是软件开发中的一种测试方法,用于验证代码中的每个单元(函数、方法等)是否按照预期工作。它的目标是在开发过程中尽早发现并修复代码中的错误,以确保代码质量和可靠性。

为什么需要测试?首先,测试可以帮助我们发现和修复代码中的bug,提高代码的质量。通过编写一系列测试用例,我们可以验证每个单元的功能是否正确,从而避免潜在的错误。

其次,测试可以帮助我们确保代码的稳定性。当我们进行更改、重构或优化代码时,测试用例可以帮助我们确保修改不会引入新的错误或导致现有功能的中断。

另外,测试也可以充当文档的角色,可以帮助他人理解和使用代码。当我们编写了一系列易于理解和使用的测试用例时,其他开发人员可以通过阅读这些测试用例来了解代码的预期行为和使用方式。

单元测试的关键是要测试每个单元的功能,而不是整个系统的功能。这样有助于隔离代码中的错误,将其限定在小范围内,更容易进行定位和修复。

在Go语言中,可以使用内置的testing包进行单元测试。通过在代码文件的末尾添加以_test.go为后缀的文件,并在其中编写单元测试函数,可以进行单元测试。测试函数的名称必须以Test开头,并接收一个*testing.T类型的参数。

下面是一个简单的示例:

package main

import "testing"

func Add(a, b int) int {
	return a + b
}

func TestAdd(t *testing.T) {
	result := Add(2, 3)
	if result != 5 {
		t.Errorf("Expected 5, but got %d", result)
	}
}
复制代码

在上述示例中,我们定义了一个Add函数用于将两个整数相加。然后编写了一个名为TestAdd的测试函数,用于测试Add函数的正确性。在测试函数中,我们调用Add函数并检查返回的结果是否符合预期。

要运行单元测试,可以使用go test命令。在包含单元测试的文件所在的目录中运行该命令,它将自动找到并运行所有的测试函数。

除了单元测试之外,还有性能测试(基准测试)等不同类型的测试方法可以用于不同的测试需求。

总的来说,单元测试是一种重要的测试方法,它可以帮助我们验证每个单元的功能,提高代码质量和稳定性。在Go语言中,可以使用内置的testing包进行单元测试并通过go test命令运行测试。

引用

该笔记内容主要来源于: 后端 - 字节内部课 (juejin.cn)