Mutex trong Golang

5 phút đọc1 lượt xem

Trong bài viết này, chúng ta sẽ thảo luận về lý do tại sao chúng ta sử dụng mutexes trong Go và tìm hiểu cách sử dụng mutex để lock dữ liệu và khắc phục race conditions.

Go cho phép chúng ta run code đồng thời bằng cách sử dụng goroutines. Tuy nhiên, khi các concurrent processes truy cập vào cùng một phần data, điều đó có thể dẫn đến race conditions.

Mutexes là cấu trúc dữ liệu được cung cấp bởi package sync. Nó có thể giúp chúng ta lock các phần dữ liệu khác nhau để mỗi lần chỉ có một goroutine có thể truy cập vào dữ liệu đó.

Sử dụng Mutex để Lock data

Chúng ta hãy xem một ví dụ, trong đó các concurrent goroutines có thể làm hỏng data bằng cách truy cập đồng thời vào nó:

// a simple function that returns true if a number is even
func isEven(n int) bool {
    return n%2 == 0
}
func main() {
    n := 0
 // goroutine 1
    // reads the value of n and prints true if its even
    // and false otherwise
    go func() {
        nIsEven := isEven(n)
   // we can simulate some long running step by sleeping
        // in practice, this can be some file IO operation
        // or a TCP network call
        time.Sleep(5 * time.Millisecond)
        if nIsEven {
            fmt.Println(n, " is even")
            return
        }
        fmt.Println(n, "is odd")
    }()
 // goroutine 2
    // modifies the value of n
    go func() {
        n++
    }()
    // just waiting for the goroutines to finish before exiting
    time.Sleep(time.Second)
}

Run code này sẽ cho chúng ta kết quả không mong muốn sau:

1  is even

Điều này xảy ra vì goroutine 1 truy cập vào biến n hai lần: một lần để kiểm tra xem nó có chẵn không và một lần nữa để in giá trị của nó. Ở giữa các bước này, goroutine 2 sẽ tăng giá trị của n.

Giá trị được in bây giờ khác với giá trị được kiểm tra. Đây được gọi là cuộc đua dữ liệu (data race.).

không sử dụng mutex dẫn đến việc đọc và ghi chồng chéo

Chúng ta nên đảm bảo rằng n không nên được ghi đè trong khi một goroutine khác đang đọc nó và ngược lại.

Chúng ta có thể sử dụng sync.Mutex để ngăn nhiều goroutine truy cập n cùng một lúc:

func main() {
    n := 0
    var m sync.Mutex
    // now, both goroutines call m.Lock() before accessing `n`
    // and call m.Unlock once they are done
    go func() {
        m.Lock()
        defer m.Unlock()
        nIsEven := isEven(n)
        time.Sleep(5 * time.Millisecond)
        if nIsEven {
            fmt.Println(n, " is even")
            return
        }
        fmt.Println(n, "is odd")
    }()
    go func() {
        m.Lock()
        n++
        m.Unlock()
    }()
    time.Sleep(time.Second)
}

Việc gọi m.Lock sẽ “lock” mutex. Nếu có bất kỳ goroutine nào khác gọi m.Lock, nó sẽ chặn luồng cho đến khi m.Unlock được gọi.

Nếu một goroutine gọi m.Lock trước khi tiến hành đọc/ghi vào dữ liệu liên quan và gọi m.Unlock sau lần access cuối cùng, thì điều đó sẽ đảm bảo rằng trong khoảng thời gian này, goroutine đó sẽ có quyền truy cập độc quyền vào dữ liệu.

Nếu bạn muốn lock cho toàn bộ phạm vi của một chức năng, tốt hơn hết bạn nên defer việc gọi Unlock thay vì tự gọi nó ở cuối

sử dụng mutex dẫn đến việc đọc và ghi độc quyền

Đây là lý do tại sao khi chạy đoạn mã trên , chúng ta nhận được kết quả chính xác:

0  is even

Mutex Reader/Writer - Sync.RWMutex

Đôi khi, việc đọc dữ liệu đồng thời được cho phép, miễn là việc data không bị ghi đè.

Trong trường hợp này, chúng ta có thể sử dụng sync.RWMutex, nó hỗ trợ các khóa khác nhau cho việc đọc và ghi dữ liệu:

package main
import (
    "fmt"
    "sync"
    "time"
)
func isEven(n int) bool {
    return n%2 == 0
}
func main() {
    n := 0
    var m sync.RWMutex
    // goroutine 1
    // Since we are only reading data here, we can call the `RLock` 
    // method, which obtains a read-only lock
    go func() {
        m.RLock()
        defer m.RUnlock()
        nIsEven := isEven(n)
        time.Sleep(5 * time.Millisecond)
        if nIsEven {
            fmt.Println(n, " is even")
            return
        }
        fmt.Println(n, "is odd")
    }()
 // goroutine 2
    go func() {
        m.RLock()
        defer m.RUnlock()
        nIsPositive := n > 0
        time.Sleep(5 * time.Millisecond)
        if nIsPositive {
            fmt.Println(n, " is positive")
            return
        }
        fmt.Println(n, "is not positive")
    }()
 // goroutine 3
    // Since we are writing into data here, we use the
    // `Lock` method, like before
    go func() {
        m.Lock()
        n++
        m.Unlock()
    }()
    time.Sleep(time.Second)
}

Khi run đoạn code này , chúng ta có thể quan sát thấy goroutines 1 và 2 có thể truy cập vào biến n đồng thời, nhưng reads (1 và 2) và writes (3) bị lock với nhau và một read hoặc write chỉ có thể bắt đầu nếu cái kia đã hoàn thành.

khóa đọc-ghi cho phép đọc đồng thời

Điều này có thể làm cho ứng dụng của bạn hoạt động hiệu quả hơn vì nó có thể reads dữ liệu này nhiều lần vì việc reads có thể diễn ra đồng thời.

Thêm Mutexes vào struct

Nếu bạn muốn gói một mutex cùng với dữ liệu của nó, bạn có thể cân nhắc sử dụng 1 struct.

Nếu muốn làm điều này cho ví dụ trước, chúng ta có thể tạo một struct để giữ giá trị số cũng như 1 giá trị mutex:

type intLock struct {
    val int
    sync.Mutex
}
func (n *intLock) isEven() bool {
    return n.val%2 == 0
}

Sau đó, chúng ta có thể sử dụng mutex trong chính struct đó để xử lý việc locking và unlocking:

func main() {
    n := &intLock{val: 0}
    go func() {
        n.Lock()
        defer n.Unlock()
        nIsEven := n.isEven()
        time.Sleep(5 * time.Millisecond)
        if nIsEven {
            fmt.Println(n.val, " is even")
            return
        }
        fmt.Println(n.val, "is odd")
    }()
    go func() {
        n.Lock()
        n.val++
        n.Unlock()
    }()
    time.Sleep(time.Second)
}

Run đoạn code này sẽ cho kết quả tương tự như trước.

Điều này có ích vì một vài lý do:

  1. Nếu bạn có nhiều phiên bản dữ liệu cần quyền truy cập độc quyền, việc kết hợp một mutex cùng với chính dữ liệu đó sẽ giúp dữ liệu ít khó hiểu hơn và dễ đọc hơn
  2. Dữ liệu có thể được truyền dưới dạng đối số của 1 hàm và mutex sẽ được truyền mặc định

Trừ khi bạn cần một mutex chung cho nhiều phần dữ liệu,thì tốt hơn hết bạn nên đưa một mutex vào trong một struct.

Những lỗi dễ mắc phải

Mặc dù mutexes có vẻ là một giải pháp tuyệt vời nhưng bạn lại rất dễ rơi vào một số bẫy thường gặp.

Bất cứ khi nào bạn gọi method Lock này, bạn phải đảm bảo rằng method Unlock được gọi cuối cùng. Điều này có vẻ đơn giản nhưng lại rất dễ bỏ sót. Hãy xem xét ví dụ này:

go func() {
    n.Lock()
    nIsEven := n.isEven()
    time.Sleep(5 * time.Millisecond)
    if nIsEven {
        fmt.Println(n.val, " is even")
        // mutex is never unlocked
        return
    }
    fmt.Println(n.val, "is odd")
    n.Unlock()
}()

Ở đây, chúng ta gọi Unlock ở cuối hàm, thay vì sử dụng defer. Bây giờ, nếu n chẵn, chúng ta sẽ in câu lệnh tương ứng và goroutine return, và Unlock sẽ không bao giờ được gọi.

Nếu điều này xảy ra, bất kỳ goroutine nào muốn truy cập vào cùng một lock sẽ bị blocked vĩnh viễn. Sử dụng defer để gọi Unlock có thể giúp bạn tránh được điều này.

Luôn mở khóa ngay khi bạn truy cập xong vào dữ liệu và không bao giờ để cho Lock không bị release - Bạn sẽ làm lãng phí tài nguyên.

Ví dụ: nếu bạn phải thực hiện một số công việc sau khi truy cập n, việc sử dụng defer có thể không phù hợp:

go func() {
    n.Lock()
    defer n.Unlock()
    nIsEven := n.isEven()
    time.Sleep(5 * time.Millisecond)
    if nIsEven {
        fmt.Println(n.val, " is even")
        return
    }
    fmt.Println(n.val, "is odd")
    // some work after printing
    time.Sleep(5 * time.Millisecond)
}()

Trong trường hợp này, n.Unlocksẽ được gọi sau time.Sleep - lệnh gọi cuối cùng, mặc dù chúng ta có thể mở khóa ngay sau khi in xong kết quả. Bằng cách sử dụng defer, chúng ta có khả năng đã trì hoãn các goroutine khác có yêu cầu quyền truy cập vào khóa này.

Trong những trường hợp như thế này, tốt hơn hết bạn nên mở khóa theo cách thủ công sau khi truy cập xong dữ liệu:

go func() {
    n.Lock()
    nIsEven := n.isEven()
    time.Sleep(5 * time.Millisecond)
    if nIsEven {
        fmt.Println(n.val, " is even")
        // unlock before returning
        n.Unlock()
        return
    }
    fmt.Println(n.val, "is odd")
    // unlock after printing `n`s value
    n.Unlock()
    // we can now release the lock 5ms earlier
    time.Sleep(5 * time.Millisecond)
}()

Tóm lại, mutex là một công cụ tuyệt vời để ngăn chặn việc truy cập dữ liệu không theo thứ tự . Có nhiều cách để sử dụng mutex và nhiều cạm bẫy có thể xảy ra, vì vậy hãy đảm bảo đánh giá trường hợp sử dụng của bạn trước khi quyết định phương pháp phù hợp.

Để biết thêm về mutexes trong Go, bạn có thể xem trang tài liệu thư viện chuẩn “sync” .

Tham khảo

  1. https://www.scaler.com/topics/golang/mutex-in-golang/

Chủ đề

Bạn thấy bài này thế nào?