+7

Tối Ưu Hóa Hiệu Suất trong Golang

Ngôn ngữ lập trình Go đã ngày càng trở nên phổ biến trong cộng đồng phần mềm và là một lựa chọn mạnh mẽ cho việc xây dựng các ứng dụng hiệu suất cao và dễ bảo trì. Nhưng không vì golang hiệu suất cao mà chúng ta không cần quan tâm đến vấn đề cải thiện hiệu suất cho các đoạn code của bản thân. Sau một thời gian tìm hiểu về cải thiện hiệu suất trong golang, mình có đúc kết lại một vài vấn đề muốn chia sẻ với mọi người để chúng ta cùng giúp đỡ nhau với hi vọng tạo nên một cộng đồng lập trình viên ngày càng lớn mạnh. Không dài dòng nữa mình xin phép được vào việc luôn =))).

Garbage Collection

Garbage Collection (GC) là gì?

Garbage Collection (GC) trong Golang là một quá trình tự động thu hồi bộ nhớ không sử dụng để giảm áp lực cho lập trình viên và đảm bảo ổn định của chương trình. Golang sử dụng mô hình "stop-the-world" trong quá trình GC, nghĩa là tất cả các Goroutine sẽ dừng lại trong khi GC đang diễn ra.Trong ngữ cảnh của Garbage Collection (GC) trong Golang, bộ nhớ không sử dụng là những đối tượng (hoặc dữ liệu) không còn được sử dụng hoặc không thể truy cập được bởi chương trình, nhưng vẫn chiếm dụng bộ nhớ. Khi một biến không còn được tham chiếu bởi bất kỳ Goroutine nào hoặc không còn được sử dụng trong chương trình, nó trở thành "rác". GC sẽ quét và xác định những đối tượng không thể truy cập được và sau đó thu hồi bộ nhớ của chúng.

Các Cấu Hình GC Quan Trọng:

1. GOGC:

Đây là biến môi trường quyết định khi nào GC sẽ chạy. Giá trị của GOGC là một phần trăm, và nếu tỷ lệ bộ nhớ được chiếm dụng vượt qua giá trị này so với bộ nhớ còn lại, GC sẽ được kích hoạt. Giả sử GOGC=20 có nghĩa là GC sẽ kích hoạt khi bộ nhớ chiếm dụng đến 20% của tổng bộ nhớ. Lúc này GC sẽ tiến hành thu hồi bộ nhớ. Trong quá trình GC thu gom bộ nhớ các Goroutines sẽ được trì hoãn cho đến khi việc thu hồi kết thúc. Ảnh Hưởng của GOGC Đến Hiệu Suất: Thời Gian Dừng (Pause Time): Thời gian mà mọi Goroutine dừng lại để GC thực hiện được gọi là "pause time". Giá trị GOGC càng thấp, thời gian dừng càng ngắn, nhưng cũng tăng áp lực lên GC vì GC phải quét rác nhiều. Hiệu Suất Tổng Thể: Nếu GC được kích hoạt quá ít, hiệu suất tổng thể của ứng dụng có thể bị ảnh hưởng do thời gian dừng, nếu đặt GOGC quá cao thì có thể dẫn đến việc tăng kích thước bộ nhớ và GC hoạt động ít thì đồng nghĩa với việc có nhiều "rác" hơn dẫn đến thời gian dọn dẹp mỗi lần cũng lâu hơn.

Ở đây mình có một ví dụ minh họa nho nhỏ:

package main

import (
	"fmt"
	"runtime"
	"sync"
	"time"
)

func worker(id int, jobs <-chan int, results chan<- int) {
	for job := range jobs {
		fmt.Printf("Worker %d started job %d\n", id, job)
		time.Sleep(100 * time.Millisecond) // Giả lập công việc mất thời gian
		fmt.Printf("Worker %d finished job %d\n", id, job)
		results <- job * 2
	}
}

func main() {
	// Thiết lập giá trị GOGC
	runtime.SetGCPercent(1)

	numJobs := 1000
	jobs := make(chan int, numJobs)
	results := make(chan int, numJobs)

	// Tạo Goroutine Pool với 3 worker
	numWorkers := 500
	var wg sync.WaitGroup
	for i := 1; i <= numWorkers; i++ {
		wg.Add(1)
		go func(workerID int) {
			defer wg.Done()
			worker(workerID, jobs, results)
		}(i)
	}

	// Gửi công việc đến Goroutine Pool
	for i := 1; i <= numJobs; i++ {
		jobs <- i
	}
	close(jobs)

	// Đợi cho tất cả các worker hoàn thành công việc
	wg.Wait()

	// Đóng channel results sau khi tất cả công việc hoàn thành
	close(results)

	// In kết quả
	for result := range results {
		fmt.Println("Result:", result)
	}
}

Trong đoạn mã trên, chúng ta thiết lập GOGC bằng runtime.SetGCPercent(1), tức là GC sẽ được kích hoạt khi bộ nhớ chiếm dụng đến 1% của tổng bộ nhớ còn lại. Công việc của các Goroutine được giả lập bằng cách sử dụng time.Sleep(100 * time.Millisecond) để tạo một thời gian ngắn mà Goroutine đang "bận rộn". Thử chạy chương trình với giá trị GOGC khác nhau và quan sát thời gian dừng của Garbage Collection để thấy sự ảnh hưởng của giá trị GOGC lên hiệu suất của Goroutine.

2. GODEBUG:

Biến này cung cấp thông tin chi tiết về GC, giúp lập trình viên theo dõi và đánh giá hiệu suất.

GODEBUG=gctrace=1 go run main.go

GODEBUG=gctrace=1: Kích hoạt ghi log chi tiết về hoạt động của Garbage Collection.

Quản Lý Concurrency

Quản lý concurrency (đồng thời) là một phần quan trọng trong việc tối ưu hóa hiệu suất của ứng dụng Golang. Đối với mô hình lập trình goroutine và channel trong Golang, quản lý concurrency giúp tránh tình trạng cạnh tranh (race conditions), đồng thời làm giảm tải lên Garbage Collection (GC) và cải thiện hiệu suất tổng thể của ứng dụng.

Dưới đây là một số chiến lược để quản lý concurrency trong Golang:

1. Sử Dụng Channel an Toàn và hiệu quả

Golang có mô hình goroutine và channel mạnh mẽ. Thay vì sử dụng biến toàn cục, mình thường ưu tiên sử dụng channel để truyền dữ liệu giữa các goroutine. Điều này giúp tránh cạnh tranh và tạo ra một mô hình đồng thời an toàn.

ch := make(chan int)

go func() {
    ch <- 42
}()

value := <-ch
fmt.Println(value)

2. Sử Dụng Wait Group để Đồng Bộ Hóa

sync.WaitGroup là một công cụ mạnh mẽ để đồng bộ hóa các goroutine. Nó giúp đảm bảo rằng tất cả các goroutine đã hoàn thành công việc của mình trước khi chương trình kết thúc.

var wg sync.WaitGroup

for i := 0; i < 5; i++ {
    wg.Add(1)
    go func(index int) {
        defer wg.Done()
        fmt.Println(index)
    }(i)
}

wg.Wait()

3. Sử Dụng Pool để Tái Sử Dụng Goroutine

Sử dụng sync.Pool để tái sử dụng goroutine và giảm áp lực lên Garbage Collection (GC). Việc này có thể cải thiện hiệu suất đặc biệt là trong các tình huống tạo và hủy nhiều goroutine liên tục

var pool = sync.Pool{
    New: func() interface{} {
        return make([]byte, 1024)
    },
}

func main() {
    data := pool.Get().([]byte)
    defer pool.Put(data)

    // Sử dụng data ở đây
    fmt.Println("Using data:", string(data))
}

4. Sử Dụng Context để Quản Lý Goroutine

context.Context giúp quản lý vòng đời của goroutine và theo dõi sự hủy bỏ của chúng. Điều này đặc biệt hữu ích khi cần hủy bỏ các goroutine một cách an toàn.

func worker(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("Worker received cancellation signal")
            return
        default:
            fmt.Println("Working...")
            time.Sleep(1 * time.Second)
        }
    }
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    go worker(ctx)

    // Do some work
    time.Sleep(3 * time.Second)

    // Hủy bỏ goroutine
    cancel()
    time.Sleep(1 * time.Second)
}

Concurrency Patterns

1. Fan-out, Fan-in

Fan-out: Chia công việc thành nhiều goroutine để xử lý đồng thời. Điều này giúp tận dụng tối đa nguồn lực hệ thống.

Fan-in: Kết hợp nhiều đầu ra thành một đầu vào. Kỹ thuật này thường được sử dụng để tổng hợp và xử lý dữ liệu từ nhiều nguồn.

package main

import (
	"fmt"
	"sync"
)

func main() {
	input := make(chan int)
	output := make(chan int)

	go producer(input)
	go worker(input, output)
	go worker(input, output)

	go func() {
		waitGroup := sync.WaitGroup{}
		waitGroup.Add(2)
		go func() {
			defer waitGroup.Done()
			for result := range output {
				fmt.Println(result)
			}
		}()
		waitGroup.Wait()
		close(output)
	}()

	select {}
}

func producer(ch chan<- int) {
	for i := 0; i < 10; i++ {
		ch <- i
	}
	close(ch)
}

func worker(input <-chan int, output chan<- int) {
	for value := range input {
		// Do some processing
		result := value * 2
		output <- result
	}
}

Trong đoạn mã trên:

Goroutine producer tạo ra các giá trị và gửi chúng vào channel input. Hai goroutine worker đồng thời nhận dữ liệu từ input, xử lý và đẩy kết quả vào channel output. Một goroutine khác đọc từ channel output và hiển thị kết quả. Cả hai worker đều gửi kết quả vào một channel output, và một goroutine khác đọc từ channel này để hiển thị kết quả. Kỹ thuật này giúp chúng ta kết hợp nhiều nguồn dữ liệu thành một nguồn duy nhất để xử lý, tăng khả năng mở rộng và tái sử dụng code.

3. Throttle

Throttle Là Gì?

Throttle giúp kiểm soát số lượng goroutine đồng thời, giảm áp lực lên hệ thống. Điều này đặc biệt hữu ích khi chúng ta làm việc với các nguồn dữ liệu lớn đôi khi chúng ta sử dụng Goroutine trong for điều này vô tình tạo ra quá nhiều Goroutine chạy đồng thời, điều này có thể gây ra chết service

package main

import (
	"fmt"
	"sync"
	"time"
)

func main() {
	input := make(chan int)
	output := make(chan int)

	go producer(input)
	go throttleWorker(input, output, 50)

	go func() {
		waitGroup := sync.WaitGroup{}
		waitGroup.Add(1)
		go func() {
			defer waitGroup.Done()
			for result := range output {
				fmt.Println(result)
			}
		}()
		waitGroup.Wait()
		close(output)
	}()

	select {}
}

func producer(ch chan<- int) {
	for i := 0; i < 1000; i++ {
		ch <- i
	}
	close(ch)
}

func throttleWorker(input <-chan int, output chan<- int, maxConcurrency int) {
	throttle := make(chan struct{}, maxConcurrency)
	var waitGroup sync.WaitGroup

	for value := range input {
		throttle <- struct{}{}
		waitGroup.Add(1)

		go func(v int) {
			defer func() {
				<-throttle
				waitGroup.Done()
			}()

			// Do some processing
			result := v * 2
			output <- result
		}(value)
	}

	waitGroup.Wait()
	close(output)
}

Trong ví dụ trên ta có :

  • Goroutine 'producer': Tạo ra các giá trị và gửi chúng vào channel input.
  • Goroutine 'throttleWorker': Nhận dữ liệu từ input, giới hạn số lượng worker đồng thời thông qua một channel throttle. Mỗi khi một goroutine worker được khởi tạo, một phần tử trống được gửi vào channel throttle. Khi goroutine hoàn thành công việc, nó giải phóng một phần tử từ channel throttle, cho phép một goroutine mới được khởi tạo.
  • Wait Group waitGroup: Để đảm bảo rằng tất cả các goroutine worker đã hoàn thành công việc của mình trước khi đóng channel output.
  • Goroutine đọc từ channel output: Hiển thị kết quả. Như chúng ta thấy cách tiếp cận này giúp kiểm soát số lượng goroutine worker đồng thời, tránh tình trạng quá tải hệ thống mặc dù chúng ta có tới 1000 Goroutine, và đồng thời đảm bảo rằng tất cả công việc được hoàn thành trước khi đóng channel output.

Kết Luận

Đối với những người làm việc với Golang, việc nâng cao kỹ năng không chỉ giúp tối ưu hóa hiệu suất mà còn là chìa khóa mở ra những cách tiếp cận mới trong việc phát triển ứng dụng. Bằng cách hiểu rõ hơn về các yếu tố như garbage collection, hiệu suất, concurrency, và sử dụng chúng hiệu quả, chúng ta có thể đạt được sức mạnh đích thực của Golang. Bài viết của mình đến đây là kết thúc, Bài viết còn nhiều thiếu sót rất mong nhận được sự góp ý chân thành từ mọi người. Cuối cùng xin cảm ơn các bạn đã dành thời gian để đọc bài viết của mình và hẹn gặp lại các bạn trong các bài viết sắp tới nhé.


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.