使用Go两年时学到的五大经验教训 - hashnode
在本文中,我将讨论其中的一些错误以及我在未来项目中尝试减轻这些错误的经验教训。这绝不是对理想解决方案的讨论,这只是我通过使用 Go 的经验学习和发展的想法:
1. Goroutines
在我看来,Go 作为一种语言非常吸引人的地方(除了它的简单性和接近 C 的性能)是它能够轻松编写并发代码。Goroutines是编写并发代码的 Go 方式。goroutine 是一个轻量级线程,或者称为绿色线程,是的,它不是内核线程。Goroutines 是绿色线程,因为它们的调度完全由Go 运行时而不是操作系统管理。Go 调度器负责将 goroutine 多路复用到真正的内核线程上,这有利于使 goroutine 在启动时间和内存要求方面非常轻量级,从而允许 go 应用程序运行数百万个 goroutine!
我认为 Go 处理并发的方式是独一无二的,一个常见的错误是用处理任何其他语言(例如 Java)并发的方式处理 Go 并发。Go 处理并发的方式有多么不同,最著名的例子之一可以总结为:
不要通过共享内存来通信,通过通信来共享内存。
一个非常常见的情况是应用程序将有多个 goroutine 访问共享内存块。因此,例如,我们正在实现一个连接池,其中有一个可用连接数组,每个 goroutine 都可以获取或释放连接。最常见的方法是使用mutex,它只允许持有 的 goroutinemutex在给定时间以独占方式访问连接数组。所以代码看起来像这样(注意一些细节被抽象出来以保持代码简洁):
|
type ConnectionPool struct {
Mu sync.Mutex
Connections []Connection
}
func (pool *ConnectionPool) Acquire() Connection {
pool.Mu.Lock()
defer pool.Mu.Unlock()
//acquire and return a connection
}
func (pool *ConnectionPool) Release(c Connection) {
pool.Mu.Lock()
defer pool.Mu.Unlock()
//release the connection c
}
这看起来很合理,但是如果我们忘记实现锁定逻辑怎么办?如果我们确实实现了它但忘记锁定众多功能之一怎么办?如果我们没有忘记锁定,而是忘记解锁怎么办?如果我们只锁定临界区的一部分(欠锁)会怎样?或者如果我们锁定不属于临界区的部分(过度锁定)怎么办?这似乎容易出错,而且通常不是 Go 处理并发的方式。
这让我们回到了 Go 的口头禅“不要通过共享内存进行通信,通过通信共享内存”。要理解这意味着什么,我们首先需要了解什么是 Go通道channel?通道是实现 goroutine 之间通信的 Go 方式。它本质上是一个线程安全的数据管道,允许 goroutine 在它们之间发送或接收数据,而无需访问共享内存块。Go 通道也可以被缓冲,这允许其控制同时调用的数量,有效地充当信号量!
因此,重新修改我们的代码以通过通信而不是锁定来共享它,我们得到了一个看起来像这样的代码(注意一些细节被抽象出来以保持代码简洁):
type ConnectionPool struct {
Connections chan Connection
}
func NewConnectionPool(limit int) *ConnectionPool {
connections := make(chan Connection, limit)
return &{ Connections: connections }
}
func (pool *ConnectionPool) Acquire() Connection {
<- pool.Connections
//acquire and return a connection
}
func (pool *ConnectionPool) Release(c Connection) {
pool.Connections <- c
//release the connection c
}
使用 Go 通道不仅减少了代码的大小和整体复杂性,而且还抽象了显式实现线程安全的需要。所以现在数据结构本身本质上是线程安全的,所以即使我们忘记了这一点,它仍然可以工作。
使用通道的好处很多,这个例子仅仅触及皮毛,但这里的教训是不要像用任何其他语言编写的那样在 Go 中编写并发代码。
2.如果可以单例,那就单例
Go 应用程序可能必须访问Database或Cache等,它们是具有连接池的资源示例,这意味着对该资源的并发连接数有限制。根据我的经验,Go 中的大多数连接对象(数据库、缓存等)都是作为线程安全的连接池构建的,可以由多个 goroutine 同时使用,而不是单个连接。
因此,假设我们有一个 Go 应用程序,它mysql通过一个sql.DB对象作为数据库进行访问,该对象本质上是一个到数据库的连接池。如果应用程序有很多 goroutines,那么创建一个新sql.DB对象是没有意义的,实际上这可能会导致连接池耗尽(注意使用后不关闭连接也会导致这种情况)。所以*sql.DB表示连接池的对象必须是单例是有道理的,所以即使 goroutine 试图创建一个新对象,它也会返回相同的对象,从而不允许有多个连接池。
创建可以在应用程序单例的生命周期内共享的对象通常是一个很好的做法,因为它封装了此逻辑并防止代码不遵守此策略。一个常见的陷阱是实现本身不是一个线程安全的创建逻辑单例 。例如,考虑下面的代码(注意一些细节被抽象出来以保持代码简洁):
|
var dbInstance *DB
func DBConnection() *DB {
if dbInstance != nil {
return dbInstance
}
dbInstance = &sql.Open(...)
return dbInstance
}
前面的代码检查单例对象是否不是nil(这意味着它之前已创建),在这种情况下它返回它,但如果是,nil则它创建一个新连接,将其分配给单例对象并返回它。原则上,这应该只创建一个数据库连接,但这不是线程安全的。
考虑 2 个 goroutine 同时调用函数DBConnection()的情况。有可能第一个 goroutine 读取dbInstance并找到它的值nil然后继续创建一个新的连接,但是在新创建的实例分配给单例对象之前,第二个 goroutine 也执行相同的检查,得出相同的结论并且继续创建一个新连接,给我们留下 2 个连接而不是 1 个。
这个问题可以使用上一节中讨论的锁来处理,但这也不是 Go 的方式。Go 支持默认线程安全的原子操作,所以如果我们可以使用保证线程安全的东西而不是显式实现它,那么让我们这样做吧!
因此,重新访问我们的代码以使其成为线程安全的,我们得到的代码看起来像这样(注意一些细节被抽象出来以保持代码简洁):
|
var dbOnce sync.Once
var dbInstance *DB
func DBConnection() *DB {
dbOnce.Do(func() {
dbInstance = &sql.Open(...)
}
return dbInstance
}
这段代码使用了一个被调用的 Go 结构sync.Once,它允许我们编写一个只会执行一次的函数。这样,即使多个 goroutine 尝试同时执行,连接创建段也能保证只运行一次。
3. 小心阻塞代码
有时你的 Go 应用程序会执行阻塞调用,可能是对可能不响应的外部服务的请求,或者可能是调用在某些条件下阻塞的函数。根据经验,永远不要假设调用会在适当的时候返回,因为有时它们不会。
通常处理这种情况的方法是设置一个超时时间,在此之后该调用将被取消并且可以继续执行。还建议(如果可能)对单独的例程执行阻塞调用,而不是阻塞主例程。
那么让我们考虑一下 Go 应用程序需要通过http. Gohttp客户端默认不会超时,但我们可以按如下方式设置超时:
|
&http.Client{Timeout: time.Minute}
虽然这工作得很好,但它非常有限,因为现在我们对通过同一个客户端(这可能是一个单例对象)执行的所有请求都有相同的超时。Go 有一个名为contex包,它允许我们将请求作用域的值、取消信号和超时传递给处理请求所涉及的所有 goroutine。我们可以这样使用它:
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
req := req.WithContext(ctx)
res, err := c.Do(req)
前面的代码允许设置每个请求的超时,还可以取消请求或在需要时传递每个请求的值。如果发送请求的goroutine派生了其他goroutine,而这些goroutine在请求超时时都需要退出,那么这将特别有用。
在这个例子中,我们很幸运:http包支持上下文,但是如果我们正在处理一个不支持的阻塞函数呢?有一种临时方法可以使用 Go 的select语句和 Go channels(再次!)来实现超时逻辑。考虑以下代码:
|
func SendValue(){
sendChan := make(chan bool, 1)
go func() {
sendChan <- Send()
}()
select {
case <-sendChan:
case <-time.After(time.Minute):
return
}
//continue logic in case send didn't timeout
}
我们有一个名为SendValue()的函数,它调用一个名为Send()的阻塞函数,该函数可能会永远阻塞。因此,我们初始化一个缓冲布尔通道,并在一个单独的goroutine上执行阻塞函数,完成后在通道中发送一个信号,而在主goroutine中,我们阻塞等待通道产生一个值(请求成功)或等待1分钟后返回。请注意,代码缺少取消Send()函数从而终止goroutine的逻辑(否则会导致goroutine泄漏)。
4. 优雅终止和清理
如果您正在编写一个长时间运行的进程,例如 Web 服务器或后台作业工作者等,您将面临突然终止的风险。这种终止可能是因为进程被调度程序终止了,或者即使您正在发布新代码并且正在推出它。
通常,长时间运行的进程可能在其内存中包含数据,如果该进程要终止,这些数据将丢失,或者它可能持有需要释放回资源池的资源。所以当一个进程被杀死时,我们需要能够执行优雅的终止。优雅地终止进程意味着我们拦截kill signal并执行特定于应用程序的关闭逻辑,以确保在实际终止之前一切正常。
因此,假设我们正在构建一个 Web 服务器,我们通常希望它能够像这样运行:
|
server := NewServer()
server.Run()
这里的关键思想是Run()无限运行的函数,从某种意义上说,如果我们在main函数结束时调用它,则进程不会退出,只有在Run()函数退出时才会退出。
可以实现服务器逻辑来检查关闭signal并仅signal在收到关闭时退出,如下所示:
|
func (server *Server) Run() {
for {
//infinite loop
select {
case <- server.shutdown:
return
default:
//do work
}
}
}
服务器循环检查信号是否通过关闭通道发送,在这种情况下它退出服务器循环,否则继续执行其工作。
现在,这个难题中唯一缺少的部分是能够截获操作系统中断,例如(SIGKILL或SIGTERM),并调用Server.Shutdown(),它执行关闭逻辑(将内存刷新到磁盘、释放资源、清理等),并发送关闭信号以终止服务器循环。我们可以通过以下方式实现:
|
func main() {
signals := make(chan os.Signal, 1)
signal.Notify(signals, os.Interrupt)
server := NewServer()
server.Run()
select {
case <-signals:
server.Shutdown()
}
}
创建了一个os.Signal类型的缓冲通道,并在发生os interrupt中断时向通道发送一个信号。main函数的其余部分运行服务器并阻止在通道上等待。当接收到一个信号时,这意味着发生了操作系统中断,而不是立即退出,它调用服务器关闭逻辑,给它一个优雅地终止的机会。
5. Go 模块 FTW
您的 Go 应用程序具有外部依赖项是很常见的,例如,您正在使用mysql 驱动程序或 redis 驱动程序或任何其他包。当您第一次构建应用程序时,构建过程将获得每个依赖项的最新版本,这很棒。现在你构建了你的二进制文件,你测试了它并且它可以工作,所以你进入了生产阶段。
一个月后,您需要添加新功能或进行修补程序,这可能不需要新的依赖项,但需要重新构建应用程序以生成新的二进制文件。构建过程还将获得每个所需软件包的最新版本,但此版本可能与您在第一次构建时获得的版本不同,并且可能包含会导致应用程序本身中断的重大更改。所以很明显,除非您明确选择 upgrade ,否则我们需要通过始终获取每个依赖项的相同版本来管理此问题。
Go.11 引入了go.mod这是在 Go 中处理依赖版本控制的新方法。当你在你的应用程序中初始化一个 Go mod 然后构建它时,它会自动生成一个go.mod文件和一个go.sum文件。mod文件看起来像这样:
|
module github.com/org/module_name
go 1.14
require (
github.com/go-sql-driver/mysql v1.5.0
github.com/onsi/ginkgo v1.12.3 // indirect
gopkg.in/redis.v5 v5.2.9
gopkg.in/yaml.v2 v2.3.0
)
它锁定了 Go 版本以及用于每个依赖项的版本,因此例如redis v5.2.9,即使 redis 存储库v5.3.0作为其最新版本发布以保证稳定性,我们也将始终获得每个构建。请注意,标记为间接的第二个依赖项意味着该依赖项不是由您的应用程序直接导入,而是由其依赖项之一导入,并且它还锁定其版本。
使用 Go mods 还有许多其他好处,例如:
- 它会自动运行go mod tidy,从而删除任何不需要的依赖项。
- 它允许您从任何目录运行代码(在 go mods 之前,go 项目必须放在特定目录中)。
- 它将整个应用程序包装成一个module可以导入到全新项目中的应用程序。如果您决定将某些逻辑包装到一个包中(例如记录器包)并将其导入到所有其他项目中,以便在多个代码库中使用该日志记录功能,这将特别有用。