九. go 高性能编程之 超时场景如何退出协程 goroutine

目录

  • 一. 协程不能退出演示
  • 二. 有缓冲区的 channel 解决协程不能退出问题
  • 三. select 尝试发送解决协程不能退出问题
  • 四. 超时复杂场景代码示例(重要)
  • 五. 强制 kill goroutine 可能吗
  • 六. 接口并发超时控制

一. 协程不能退出演示

  1. 如下,
  1. 提供了一个doBadthing() 函数,接收一个channel通道,
  2. 提供了一个timeout()行数,该函数中,启动了子协程执行函数 f,函数执行结束后,将向 channel done 发送结束信号
  3. 利用time.After 启动了一个异步的定时器,返回一个 channel,当超过指定的时间后,该 channel 将会接受到信号
  4. 使用 select 阻塞等待 done 或 time.After 的信息,若超时,则返回错误,若没有超时,则返回 nil
func doBadthing(done chan bool) {
	time.Sleep(time.Second)
	done <- true
}

func timeout(f func(chan bool)) error {
	done := make(chan bool)
	go f(done)
	select {
	case <-done:
		fmt.Println("done")
		return nil
	case <-time.After(time.Millisecond):
		fmt.Println("超时放弃执行")
		return fmt.Errorf("timeout")
	}
}
  1. 运行测试
func executeTest(t *testing.T, f func(chan bool)) {
	t.Helper()
	for i := 0; i < 1000; i++ {
		timeout(f)
	}
	time.Sleep(time.Second * 2)
	t.Log(runtime.NumGoroutine())
}

func TestBadTimeout(t *testing.T)  {
	executeTest(t, doBadthing)
}
  1. 发现问题:
  1. 最终程序中存在着 1002 个子协程,说明即使是函数执行完成,协程也没有正常退出。那如果在实际的业务中,我们使用了上述的代码,那越来越多的协程会残留在程序中,最终会导致内存耗尽(每个协程约占 2K 空间),程序崩溃
  2. done 是一个无缓冲区的 channel,如果没有超时,doBadthing 中会向 done 发送信号,select 中会接收 done 的信号,因此 doBadthing 能够正常退出,子协程也能够正常退出。但是,当超时发生时,select 接收到 time.After 的超时信号就返回了,done 没有了接收方(receiver),而 doBadthing 在执行 1s 后向 done 发送信号,由于没有接收者且无缓存区,发送者(sender)会一直阻塞,导致协程不能退出

二. 有缓冲区的 channel 解决协程不能退出问题

func timeoutWithBuffer(f func(chan bool)) error {
	//1使用有缓存channel
	done := make(chan bool, 1)
	go f(done)
	select {
	case <-done:
		fmt.Println("done")
		return nil
	case <-time.After(time.Millisecond):
		return fmt.Errorf("timeout")
	}
}

//运行测试方法
func TestBufferTimeout(t *testing.T) {
	for i := 0; i < 1000; i++ {
		timeoutWithBuffer(doBadthing)
	}
	time.Sleep(time.Second * 2)
	t.Log(runtime.NumGoroutine())
}
  1. 最终结果: 协程数量下降为 2,创建的 1000 个子协程成功退出

三. select 尝试发送解决协程不能退出问题

  1. 如下: 使用 select 尝试向信道 done 发送信号,如果发送失败,则说明缺少接收者(receiver),即超时了,那么直接退出即可
func executeTest(t *testing.T, f func(chan bool)) {
	t.Helper()
	for i := 0; i < 1000; i++ {
		timeout(f)
	}
	time.Sleep(time.Second * 2)
	t.Log(runtime.NumGoroutine())
}

func timeout(f func(chan bool)) error {
	done := make(chan bool)
	go f(done)
	select {
	case <-done:
		fmt.Println("done")
		return nil
	case <-time.After(time.Millisecond):
		fmt.Println("超时放弃执行")
		return fmt.Errorf("timeout")
	}
}

func doGoodthing(done chan bool) {
	time.Sleep(time.Second)
	select {
	case done <- true:
	default:
		return
	}
}

//运行测试
func TestGoodTimeout(t *testing.T) {
	executeTest(t, doGoodthing) 
}

四. 超时复杂场景代码示例(重要)

  1. 例如将任务拆分为多段,只检测第一段是否超时,若没有超时,后续任务继续执行,超时则终止
  2. 这种场景在实际的业务中更为常见,例如将服务端接收请求后的任务拆分为 2 段,一段是执行任务,一段是发送结果,就会有两种情况:
  1. 任务正常执行,向客户端返回执行结果。
  2. 任务超时执行,向客户端返回超时。
  1. 这种情况下,就只能够使用 select,而不能能够设置缓冲区的方式了。因为如果给信道 phase1 设置了缓冲区,phase1 <- true 总能执行成功,那么无论是否超时,都会执行到第二阶段,而没有即时返回,这是我们不愿意看到的。对应到上面的业务,就可能发生一种异常情况,向客户端发送了 2 次响应: 任务超时执行,向客户端返回超时,一段时间后,向客户端返回执行结果
  2. 缓冲区不能够区分是否超时了,但是 select 可以(没有接收方,信道发送信号失败,则说明超时了)

五. 强制 kill goroutine 可能吗

  1. 即时超时返回了,但是子协程仍在继续运行,直到自己退出。有可能在超时的时候,就强制关闭子协程吗: 不能,goroutine 只能自己退出,而不能被其他 goroutine 强制关闭或杀死
  2. 一些建议: 由于goroutine 不能被强制 kill,在超时或其他类似的场景下,为了 goroutine 尽可能正常退出,建议如下:
  1. 尽量使用非阻塞 I/O(非阻塞 I/O 常用来实现高性能的网络库),阻塞 I/O 很可能导致 goroutine 在某个调用一直等待,而无法正确结束
  2. 业务逻辑总是考虑退出机制,避免死循环。
  3. 任务分段执行,超时后即时退出,避免 goroutine 无用的执行过多,浪费资源

六. 接口并发超时控制

  1. 需求,web服务中的test接口,同一时间内允许最高5个请求,超过部分等待指定时间,如果不能执行返回异常
  2. 示例一(有问题,如果一个请求以及积压4秒,在快要5秒时,控制并发数的resultChan通道减少了一个,积压4秒的请求开始执行业务逻辑,在执行业务的过程中 timerCtx到达5秒,将异常返回给了上层,实际这个请求已经开始执行了)
package main

import (
	"context"
	"fmt"
	"net/http"
	"time"
)

func main() {
	http.HandleFunc("/test", func(w http.ResponseWriter, r *http.Request) {
		// 1. 创建一个带有超时时间的Context对象
		ctx, cancel := context.WithTimeout(r.Context(), time.Second*5)
		// 2. 在函数返回前取消Context对象
		defer cancel()
		// 创建一个缓冲大小为1的结果通道
		resultChan := make(chan bool, 1)

		go func() {
			// 模拟处理请求的耗时操作
			time.Sleep(time.Second)
			// 将结果写入结果通道
			resultChan <- true
		}()

		select {
		case res := <-resultChan: // 等待结果通道返回结果
			fmt.Fprintf(w, "result=%v\n", res)
		case <-ctx.Done():
			err := ctx.Err()
			if err == context.DeadlineExceeded {
				// 如果超时则返回timeout错误状态码
				http.Error(w, "timeout", http.StatusRequestTimeout)
			} else {
				// 如果出现其他问题则返回bad request错误状态码
				http.Error(w, err.Error(), http.StatusBadRequest)
			}
		}
	})
	// 启动HTTP服务监听端口8080
	http.ListenAndServe(":8080", nil)
}
  1. 示例二(也存在上面所说的问题)
package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"time"
)

// 创建一个信号量,限制最大并发请求数
var sem = make(chan struct{}, 5)

// test接口的处理函数
func testHandler(w http.ResponseWriter, r *http.Request) {
	// 创建一个超时上下文,设置超时时间为30秒
	ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
	defer cancel()
	// 尝试获取信号量,如果超时则返回错误信息
	select {
	case sem <- struct{}{}:
		defer func() { <-sem }()
	case <-ctx.Done():
		http.Error(w, "Too many requests, please try again later.", http.StatusServiceUnavailable)
		return
	}
	// 模拟处理请求的逻辑,这里简单地打印一句话
	fmt.Fprintln(w, "Hello, this is a test interface.")
}

func main() {
	// 注册test接口的路由
	http.HandleFunc("/test", testHandler)
	// 启动web服务,监听8080端口
	log.Println("Starting web service on port 8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}
  1. 上面两种方式处理超时存在的弊端:
  1. 创建一个有缓冲channel控制并发数, 使用select-case同时监听这个有缓冲通道和time下After(time.Millisecond)指定时间填充信号的channel,通过这两个channel取数据,然后协程调用业务函数执行,业务执行完毕向有缓冲通道中写数据,这时候select-case监听的有缓冲通道就能拿到数据成功返回,假设业务函数执行超时,select-case监听的time下After(time.Millisecond)指定时间填充信号的channel返回数据,说明超时了直接返回异常
  2. 创建一个有缓冲channel控制并发数,使用golang提供的Context下的timerCtx,设置这个Context的超时时间,select-case同时监听这个有缓冲通道和context的Done(),还是通过协程调用业务函数执行,业务执行完毕向控制协程并发的channel中添加数据,当select-case通过context的Done()接收到数据,说明超时了
  3. 问题: 这两种方式如果处理不好都会出现一个问题:果一个请求以及积压4秒,在快要5秒时,控制并发数的resultChan通道减少了一个,积压4秒的请求开始执行业务逻辑,在执行业务的过程中 timerCtx到达5秒,将异常返回给了上层,实际这个请求已经开始执行了,虽然这个问题在这两种方式下可以解决,比如当接收到超时信号时再增加一个判断当前超时的请求是否已经开始执行的标识,但是比较啰嗦,

你可能感兴趣的:(#,四.,Go,高性能编程相关问题,#,六.,Go,协程调度原理相关,golang,数据库,服务器)