Những vấn đề cần lưu ý khi lập trình tương tranh (concurent programming)

Những vấn đề cần lưu ý khi lập trình tương tranh (concurent programming)

Chúng ta đều biết đến định luật Moore, được đề xuất bởi Gordon Moore vào năm 1965, rằng “Số lượng transistor trên mỗi đơn vị inch vuông sẽ tăng lên gấp đôi sau mỗi năm.” Định luật Moore đã định hướng sự phát triển của ngành công nghệ thông tin trong hàng chục năm, mang theo kỳ vọng của mọi người về việc cải thiện liên tục tốc độ tính toán của máy tính. Tuy nhiên kể từ năm 2012, khi mà kích cỡ của transistor đã nhỏ đến gần với giới hạn vật lý, các công ty trong ngành buộc phải phụ thuộc vào những cách khác để duy trì việc gia tăng sức mạnh của máy tính. Hai cách phổ biến nhất là sử dụng vi xử lý nhiều nhân và tính toán phân tán dựa trên điện toán đám mây (cloud computing). Mà để tận dụng được sức mạnh của hai nền tảng kể trên, đòi hỏi lập trình viên phải thay đổi tư duy so với trước kia, chuyển từ tổ chức chương trình máy tính theo phương pháp lập trình tuần tự (sequential programming) sang lập trình tương tranh (concurent programming) – nghĩa là trong một phần mềm, thay vì tất cả phải chạy theo một thứ tự từ trước đến sau, sẽ có những đoạn chương trình được chạy đồng thời (hoặc gần như đồng thời) với nhau.

Vậy khi bắt đầu thực hiện lập trình tương tranh, sẽ có những vấn đề gì phát sinh mà trong kinh nghiệm nhiều năm lập trình tuần tự, chúng ta chưa bao giờ phải suy nghĩ đến chúng? Trong bài viết này, mình xin liệt kê vài vấn đề phổ biến nhất mà mỗi lập trình viên khi tiến hành lập trình tương tranh cần hết sức lưu ý, tránh gây ra những bug không đáng có trong chương trình.

(Các ví dụ trong bài viết sẽ sử dụng ngôn ngữ Go – ngôn ngữ nổi tiếng nhất hiện nay trong việc hỗ trợ lập trình tương tranh.)

1. Race Condition

Race Condition là tình huống khi một chương trình không thể đảm bảo hai hay nhiều thao tác xảy ra theo đúng một thứ tự mong muốn.
Một ví dụ đơn giản:

var data int
go func() { // Thực hiện một routine (tiến trình) độc lập với tiến trình chính. Chi tiết hơn về routine tham khảo ở đây
  data++
}()
if data == 0 {
  fmt.Printf("the value is %v.\n", data)
}

Trong đoạn code trên, hàm thay đổi giá trị data được chạy trên một routine độc lập với đoạn code chính. Do đó, chương trình có đầu ra ngẫu nhiên với ba kết quả khác nhau:
– Trường hợp 1: Giá trị data được cập nhật trước câu lệnh if data == 0, chương trình không in ra gì
– Trường hợp 2: Giá trị data được cập nhật sau câu lệnh if data == 0 nhưng trước câu lệnh fmt.Printf, chương trình in ra giá trị là 1.
– Trường hợp 3: Giá trị data được cập nhật sau câu lệnh fmt.Printf, chương trình in ra giá trị là 0.

2. Deadlock

Deadlock là tình huống khi những tiến trình trong chương trình đang chờ đợi lẫn nhau, dẫn đến chương trình không thể tiếp tục nếu không có sự can thiệp từ bên ngoài.
Một ví dụ đơn giản:

type value struct {  
  mu sync.Mutex   // Biến dùng để khoá độc quyền, tại một thời điểm chỉ có một tiến trình có thể gọi lệnh Lock()
  value int
}
var wg sync.WaitGroup // Khai báo một WaitGroup. Chi tiết về WaitGroup trong Go có thể tham khảo ở đây

var printSum = func(v1, v2 *value) { // Hàm chính của chương trình, mục đính là in giá tổng giá trị lưu trong hai cấu trúc v1, v2
  defer wg.Done()        // Giảm counter của WaitGroup đi 1. (Từ khoá defer sẽ đưa câu lệnh xuống cuối stack thực thi của hàm. Chi tiết về defer trong Go có thể tham khảo ở đây)
  v1.mu.Lock()           // Khoá v1
  defer v1.mu.Unlock()  // Mở khoá v1 (Từ khoá defer sẽ đưa câu lệnh xuống cuối stack thực thi của hàm)
  time.Sleep(2*time.Second)
  v2.mu.Lock()           // Khoá v2
  defer v2.mu.Unlock()   // Mở khoá v2 (Từ khoá defer sẽ đưa câu lệnh xuống cuối stack thực thi của hàm)
  fmt.Printf("sum=%v\n", v1.value + v2.value)
}

func main() {
  var a, b value
  wg.Add(2)  // Tăng WaitGroup counter lên 2 
  go printSum(&a, &b)    // Chạy routine đầu tiên, truy cập a trước b sau
  go printSum(&b, &a)    // Chạy routine thứ hai, truy cập b trước a sau
  wg.Wait() // Đợt WaitGroup counter giảm đến 0 thì chương trình mới kết thúc
}

Trong ví dụ trên, routine thứ nhất truy cập a trước b sau còn routine thứ hai thì truy cập b trước a sau. Kết quả là trong khi routine thứ nhất giữ khoá ở a và chờ để khoá giá trị ở b thì routine thứ hai lại đang giữ khoá ở b và chờ để khoá giá trị ở a. Tại thời điểm thực thi, do Go có khả năng phát hiện deadlock nên sau một thời gian, chương trình sẽ trả về thông báo:
fatal error: all goroutines are asleep – deadlock!

Năm 1971, Edgar Coffman đã liệt kê ra bốn điều kiện để một deadlock xảy ra:
– Mutual Exclusion (Loại trừ lẫn nhau): Có một tiến trình đồng bộ giữ độc quyền một tài nguyên tại một thời điểm bất kỳ.
(Trong ví dụ trên, hàm printSum muốn giữ độc quyền cả hai biến a và b)
– Wait For Condition (Giữ và chờ): Có một tiến trình đồng bộ đang đồng thời giữ một tài nguyên và chờ thêm một tài nguyên khác.
(Trong ví dụ trên, routine thứ nhất đang giữ khoá ở a và chờ để giữ khoá ở b, routine thứ hai đang giữ khoá ở b và chờ để giữ khoá ở a)
– No Preemption (Không ưu tiên): Khi một tiến trình đồng bộ giữ tài nguyên thì tiến trình khác chỉ lấy được tài nguyên đó khi tiến trình đang giữ chủ động giải phóng.
(Trong ví dụ trên, rõ ràng hai routine đều có độ ưu tiên ngang nhau, không có routine nào được quyền lấy tài nguyên mà routine kia đang giữ)
– Circular Wait (Đợi vòng tròn): Có những tiến trình trong chương trình tạo thành một chuỗi vòng tròn, trong đó tiến trình sau đang chờ tài nguyên được giải phóng khỏi tiến trình trước.
(Trong ví dụ trên, routine thứ nhất đang chờ b được giải phóng từ routine thứ hai. Routine thứ hai lại đang chờ a được giải phóng từ routine thứ nhất)

Như vậy, để ngăn chặn deadlock, chúng ta cần đảm bảo ít nhất một trong bốn điều kiện kể trên sẽ không xuất hiện trong chương trình của mình.

3. Livelock

Livelock là tình huống khi có những tiến trình vẫn hoạt động tích cực nhưng thực chất lại không có tiến triển gì.
Một ví dụ trực quan là khi hai người đi đối diện với nhau trên hành lang. Khi bạn bước sang phía bên trái của bạn thì đồng thời người kia cũng thế, khi bạn bước phía bên phải của bạn thì đồng thời người kia cũng thế. Kết cục, cả hai người liên tục di chuyển nhưng vẫn không thể bước về phía trước.
Minh hoạ trường hợp trên bằng code:

package main

import (
  "fmt"
  "sync"
  "bytes"
  "sync/atomic"
  "time"
)

var cadence = sync.NewCond(&sync.Mutex{}) // Tạo biến phục vụ việc đồng bộ hoá các tiến trình
var takeStep = func() {  
  cadence.L.Lock()
  cadence.Wait()    // Khi gọi lệnh này, tiến trình sẽ đợi đến lệnh broadcast để chạy tiếp
  cadence.L.Unlock()
}

var tryMove = func(dir *int32) bool { // Tạo một hàm mô tả việc di chuyển của tiến trình
  atomic.AddInt32(dir, 1)  // Cộng vào biến dir thêm 1 đơn vị, tương ứng việc đã có thêm 1 tiến trình di chuyển vào hướng mà biến dir đại diện
  takeStep()               // Đồng bộ hoá, chờ khi có lệnh broadcast thì mới di chuyển tiếp
  if atomic.LoadInt32(dir) == 1 { // Nếu chỉ có 1 tiến trình đang di chuyển vào hướng đó ==> việc di chuyển thành công, 
    atomic.AddInt32(dir, -1)
    return true
  }
  takeStep()              // Nếu không thành công thì tiến hành bước ra ngoài, giảm biến dir đi 1
  atomic.AddInt32(dir, -1)
  return false
}

var left, right int32    // Tạo hai biến đại diện hai hướng đi trái và phải 
var walk = func(walking *sync.WaitGroup, name string) { // Hàm thực hiện việc di chuyển
  var out bytes.Buffer
  defer walking.Done()
  fmt.Fprintf(&out, "%v is trying to scoot:", name)
  for {
    if tryMove(&left) || tryMove(&right)  {  // Trong trường hợp di chuyển sang bên trái không được thì sẽ di chuyển sang bên phải
      return
    }
  }
}

func main() {
  go func() {
    for range time.Tick(1*time.Millisecond) {
      cadence.Broadcast()   // Cứ định kì 1 mili giây, sẽ thông qua biến cadence để thông báo cho các tiến trình đang Wait thoát khỏi trạng thái chờ, tiếp tục thực hiện
    }
  }()
  var peopleInHallway sync.WaitGroup 
  peopleInHallway.Add(2)
  go walk(&peopleInHallway, "Alice")    // Tạo 2 routine tương ứng với hai tên Alice và Barbara
  go walk(&peopleInHallway, "Barbara")
  peopleInHallway.Wait()
}

Trong ví dụ trên, hai routine sẽ không ở trạng thái chờ đợi lẫn nhau như deadlock mà liên tục gọi đến hàm tryMove(&left)tryMove(&Right), song vẫn không thể kết thúc được. Trong thực tế livelock rất hiếm khi xảy ra so với deadlock và cũng khó phát hiện ra hơn.

4. Starvation

Starvation là tình huống khi có những tiến trình hoạt động không đúng hiệu năng mong muốn vì thiếu tài nguyên cần thiết.

Một ví dụ đơn giản:

package main

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

var wg sync.WaitGroup
var sharedLock sync.Mutex    // Khoá độc quyền
const runtime = time.Second  // Cố định thời gian chạy cho mỗi tiến trình là 1 giây
var firstWorker = func() {   // Thiết lập tiến trình đầu tiên với chế độ mỗi vòng chạy sẽ sleep một lần 3 nano giây
  defer wg.Done()
  var count int              // Biến đếm số lần thực thi của tiến trình
  for begin := time.Now(); time.Since(begin) <= runtime; {
    sharedLock.Lock()
    time.Sleep(3*time.Nanosecond)
    sharedLock.Unlock()
    count++
  }
  fmt.Printf("First worker was able to execute %v work loops\n", count)
}

var secondWorker = func() {  // Thiết lập tiến trình thứ hai với chế độ mỗi vòng chạy sẽ sleep ba lần - mỗi lần 1 nano giây
  defer wg.Done()
  var count int              // Biến đếm số lần thực thi của tiến trình
  for begin := time.Now(); time.Since(begin) <= runtime; {
    sharedLock.Lock()
    time.Sleep(1*time.Nanosecond)
    sharedLock.Unlock()
    sharedLock.Lock()
    time.Sleep(1*time.Nanosecond)
    sharedLock.Unlock()
    sharedLock.Lock()
    time.Sleep(1*time.Nanosecond)
    sharedLock.Unlock()
    count++
  }
  fmt.Printf("Second worker was able to execute %v work loops.\n", count)
}

func main() {
  wg.Add(2)
  go firstWorker()
  go secondWorker()
  wg.Wait()
}

Trong ví dụ trên, hai tiến trình cùng thực hiện một nhiệm vụ là cộng vào biến đếm sau mỗi 3 nano giây và cùng chia sẻ một sharedLock độc quyền. Nhưng vì mỗi tiến trình chạy theo một phương thức khác nhau nên kết quả khi chạy cho thấy, trong cùng một khoảng thời gian, số lần thực hiện của tiến trình thứ nhất nhiều gấp đôi số lần thực hiện của tiến trình thứ hai! Trong chương trình với nhiều tiến trình truy cập các tài nguyên đồng thời, chúng ta cần để ý xem có xuất hiện hiện tượng một tiến trình quan trọng nào đó vì bị tranh chấp tài nguyên mà không thể hoạt động đúng với hiệu năng mong đợi, hoặc thâm chí không thể thực hiện được hay không.

Cuối

Thực ra, nội dung bài viết chỉ là tóm tắt chương một của cuốn sách Concurrency in Go: Tools and Techniques for Developers. Vậy Golang có mô hình ngôn ngữ ra sao và có những biện pháp kĩ thuật cụ thể nào trong việc đối ứng với các vấn đề thường gặp trong lập trình tương tranh? Hãy chờ đợi câu trả lời ở những bài viết sau, khi chúng ta tiếp tục theo dõi những nội dung trong cuốn sách.

COMMENTS