Hôm nay, chúng ta hãy cùng thảo luận về cách sử dụng và các trường hợp áp dụng của errGroup
, một gói thường được sử dụng. Tại sao nó hữu ích? Nhìn chung, khi chúng ta sử dụng goroutine, chúng ta không thể trả về giá trị. Nếu bạn muốn truyền ra kết quả thực thi goroutine, bạn thường phải sử dụng kênh. Gói errGroup
phù hợp nếu bạn muốn biết khi nào goroutine bạn mở gặp lỗi trong quá trình thực thi và ngừng hoạt động, và tôi cần biết giá trị lỗi.
Cách sử dụng errGroup
Gói cần được tải xuống và cài đặt trước.
go get -u golang.org/x/sync
Một ví dụ về cách sử dụng nó sẽ là
package main
import (
"fmt"
"log"
"net/http"
"golang.org/x/sync/errgroup"
)
func main() {
eg := errgroup.Group{}
eg.Go(func() error {
return getPage("https://blog.kennycoder.io")
})
eg.Go(func() error {
return getPage("https://google.com")
})
if err := eg.Wait(); err != nil {
log.Fatalf("get error: %v", err)
}
}
func getPage(url string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("fail to get page: %s, wrong statusCode: %d", url, resp.StatusCode)
}
log.Printf("success get page %s", url)
return nil
}
- Đầu tiên, tạo một cấu trúc nhóm. Bản thân cấu trúc này không cần thiết lập bất kỳ trường nào, vì vậy chỉ cần tạo một cấu trúc trống là đủ.
- Cấu trúc nhóm chỉ cung cấp hai hàm,
Go
vàWait
, thực sự khá giống với waitGroup. - Hàm Go nhận một hàm làm tham số và trả về một lỗi. Hàm này thực sự là hàm mà bạn muốn thực thi trong goroutine. Trong ví dụ trên,
getPage
được đặt trongGo
. Việc triển khaigetPage
rất đơn giản. Nó gọihttp.Get(url)
và trả về lỗi nếustatusCode
không phải là 200. Nếu không, nó sẽ ghi nhật ký để cho biết rằng việc thực thi đã thành công. - Cuối cùng, gọi
Wait
có nghĩa là nó sẽ bắt đầu chặn, tương tự như phương thứcWait
của waitGroup. Nó sẽ chờ tất cả các goroutine mà bạn đã mở hoàn thành thực thi trước khi thoát khỏiWait
. Một điểm khác biệt làWait
sẽ trả về một lỗi, xuất phát từ lỗi được trả về bởi một trong các goroutine của bạn.
Trong ví dụ trên, Wait()
tự nó không trả về lỗi vì tất cả các trang web đều có thể truy cập bình thường. Nếu bạn thay đổi một trong các URL thành URL không tồn tại, bạn sẽ thấy hiệu ứng.
2021/10/03 11:52:23 success get page https://google.com
2021/10/03 11:52:23 get error: Get "https://kenny.example.com": dial tcp: lookup kenny.example.com: no such host
exit status 1
Nếu cả hai goroutine của bạn đều cố gắng truy cập vào một URL không tồn tại:
2021/10/03 11:53:52 get error: Get "https://kenny.example.com": dial tcp: lookup kenny.example.com: no such host
exit status 1
Bạn sẽ thấy rằng chỉ có một lỗi được in ra ở cuối vì errGroup
chỉ lưu trữ lỗi từ một goroutine. Lỗi của goroutine nào được lưu trữ phụ thuộc vào goroutine nào gặp lỗi trước và sẽ được lưu trữ. Các lỗi từ các goroutine tiếp theo sẽ không được lưu trữ.
Bạn có thể cảm thấy rằng những gì bạn muốn là biết kết quả của tất cả các lỗi, và việc chỉ cung cấp cho tôi lỗi từ một goroutine là không hữu ích. Trong ví dụ trên, nó thực sự không lý tưởng vì bạn sẽ không biết URL nào không thể truy cập được. Vì vậy, cá nhân tôi nghĩ rằng trường hợp mà errGroup phù hợp là khi các tác vụ bạn muốn thực thi là giống nhau hoặc cùng loại. Ví dụ: nếu bạn có nhiều goroutine cần truy cập cùng một dịch vụ để lấy thông tin khác nhau. Do đó, khi một trong các goroutine thất bại, lý do có thể xảy ra nhất là sự cố mạng, và các goroutine khác truy cập cùng một dịch vụ cũng sẽ không thể truy cập thành công. Ngoài ra, nếu những gì bạn muốn là khi một goroutine thất bại, ngay cả khi các goroutine khác đã hoàn thành thành công, thì nó cũng không hữu ích, thì errGroup rất phù hợp.
Tuy nhiên, chỉ sử dụng cấu trúc Group trống là không hữu ích.
Khi errGroup
đang chạy, ngay cả khi một trong các goroutine của bạn gặp lỗi, nó sẽ không hủy bỏ các goroutine khác. Điều này có nghĩa là các goroutine khác không thể bị hủy kịp thời, và không thể biết liệu các goroutine đã thoát đúng cách hay chưa.
errGroup đã xem xét tình huống này, vì vậy nó chọn sử dụng phương pháp ngữ cảnh để hủy bỏ các goroutine khác.
Một ví dụ
func main() {
eg, ctx := errgroup.WithContext(context.Background())
eg.Go(func() error {
for i := 0; i < 10; i++ {
select {
case <-ctx.Done():
log.Printf("goroutine should cancel")
return nil
default:
if err := getPage("https://blog.kennycoder.io"); err != nil {
return err
}
time.Sleep(1 * time.Second)
}
}
return nil
})
eg.Go(func() error {
for i := 0; i < 10; i++ {
select {
case <-ctx.Done():
log.Printf("goroutine should cancel")
return nil
default:
if err := getPage("https://google.com"); err != nil {
return err
}
time.Sleep(1 * time.Second)
}
}
return nil
})
if err := eg.Wait(); err != nil {
log.Fatalf("get error: %v", err)
}
}
- errGroup cung cấp WithContext để đặt ngữ cảnh cha của bạn vào, và trả về một cấu trúc nhóm và ngữ cảnh. Ngữ cảnh này thực sự là một ngữ cảnh hủy bỏ.
- Sau khi lấy được ngữ cảnh hủy bỏ, bạn có thể đặt nó vào hàm
Go
và sử dụngselect <-ctx.Done()
để biết có nên hủy bỏ hay không và do đó kết thúc goroutine. Trong trường hợp này, tôi truy cập URL mười lần. Nếu có lỗi một lần, tôi trả về err, và nếu tôi nhận đượcctx.Done()
, tôi trả về nil để kết thúc goroutine. Tôi truy cập một trong các URL không chính xác để xem hiệu ứng:
2021/10/03 12:11:40 success get page https://google.com
2021/10/03 12:11:41 goroutine should cancel
2021/10/03 12:11:41 get error: Get "https:kenny.example.com": http: no Host in request URL
exit status 1
Như bạn thấy, goroutine thứ hai đã truy cập thành công URL một lần, nhưng nó phải in ra nhật ký và kết thúc goroutine thứ hai vì nó đã nhận được ctx.Done
trước khi truy cập thành công URL lần thứ hai. Cuối cùng, lỗi Wait
đã in ra lỗi từ goroutine đầu tiên.
Cách tiếp cận này đảm bảo rằng khi một goroutine gặp lỗi, nó cũng thông báo cho các goroutine khác kết thúc công việc của chúng và đảm bảo rằng các goroutine có thể thoát bình thường.
errGroup hoạt động như thế nào bên trong
Hãy xem cấu trúc của nó.
type Group struct {
cancel func()
wg sync.WaitGroup
errOnce sync.Once
err error
}
Đây là cấu trúc của cấu trúc Group
, cho thấy rằng có một cancel func()
được sử dụng cho WithContext
đã đề cập trước đó. Sự xuất hiện của WaitGroup
cho thấy rằng errGroup cũng được triển khai thông qua WaitGroup
, và Once
là để chỉ chấp nhận một lỗi. err
là giá trị lỗi được trả về bởi Wait
ở cuối.
Dưới đây là hàm Go
func (g *Group) Go(f func() error) {
g.wg.Add(1)
go func() {
defer g.wg.Done()
if err := f(); err != nil {
g.errOnce.Do(func() {
g.err = err
if g.cancel != nil {
g.cancel()
}
})
}
}()
}
Thông qua wg.Add(1)
, một goroutine được mở bên trong để thực thi hàm được truyền vào, và giá trị trả về được kiểm tra lỗi. Nếu có lỗi, errOnce.Do
được sử dụng để lưu trữ lỗi. Bởi vì tính năng của Once
là bất kể bạn truyền vào hàm nào, Once
sẽ chỉ thực thi một lần, ngay cả khi nhiều goroutine trả về lỗi. Do đó, g.err = err
sẽ chỉ được đặt một lần, và giá trị của nó sẽ không thay đổi một khi nó được đặt thành công.
Cuối cùng, nó cũng sẽ kiểm tra xem cancel
có bằng nil hay không. Nếu nó không bằng nil, điều đó có nghĩa là gì? Điều đó có nghĩa là errGroup
được tạo bằng WithContext
, vì vậy cancel
sẽ không bằng nil. Tại thời điểm này, g.cancel()
sẽ được gọi để hủy bỏ các goroutine khác cho bạn, và ctx.Done()
sẽ có giá trị.
Và vì nó được triển khai thông qua WaitGroup
, g.wg.Done()
cần được gọi sau khi mỗi goroutine hoàn thành công việc của nó để giảm nó.
Dưới đây là hàm Wait
:
func (g *Group) Wait() error {
g.wg.Wait()
if g.cancel != nil {
g.cancel()
}
return g.err
}
Tương tự, g.wg.Wait()
được gọi, điều này gây ra hiệu ứng chặn. Sau khi tất cả các goroutine đã hoàn thành, nó sẽ kiểm tra xem cancel
có bằng nil hay không và hủy bỏ các goroutine khác theo cùng một cách. Lý do để làm điều này là hướng tới việc hủy bỏ ngữ cảnh này hơn. Sau cùng, ngữ cảnh cancel
này tồn tại cho errGroup này, và nó nên được đặt lại sau khi tất cả các tác vụ đã hoàn thành.
func WithContext(ctx context.Context) (*Group, context.Context) {
ctx, cancel := context.WithCancel(ctx)
return &Group{cancel: cancel}, ctx
}
Như bạn thấy, một ngữ cảnh hủy bỏ thực sự được thiết lập, và ctx
được trả về cho phía client để sử dụng, và hàm cancel
được lưu trữ.
Tóm lại, thiết kế của errGroup
rất đơn giản, và khéo léo sử dụng WaitGroup
và Context
để đạt được khả năng chờ tất cả các goroutine và lấy lỗi, và sử dụng Context
để cho phép phía client thiết kế việc triển khai thoát goroutine.