Cảm ơn bạn đã đọc và ủng hộ blog KTMT ʘ‿ʘ Từ bây giờ chúng tôi sẽ là kipalog.com !

1. Mở đầu

Nhân tiện trả lời thắc mắc của bạn kiennt về shared lockexclusive lock được nhắc đến trong bài viết Giới Thiệu Một Số Storage Engine Của Mysql, mình viết bài này để giải thích khái niệm và ý nghĩa của exclusive lock, shared lock, MVCC cũng như làm rõ một số điểm chưa rõ ràng trong bài viết trên.

2. Cơ chế và ý nghĩa của Lock và MVCC

2.1 Khái niệm và ý nghĩa của lock

Lock (khóa) là cơ chế đồng bộgiới hạn truy cập đến tài nguyên đươc chia sẻ trong một môi trường có nhiều luồng xử lý cùng truy cập.

Nói một cách hình tượng, lock giống một cái cờ tuyên bố chủ quyền đối với tài nguyên máy tính. Mỗi luồng xử lý (thread) khi truy cập tài nguyên dùng chung nào đó sẽ phải “dựng cờ lên” để báo cho các luồng xử lý khác biết tài nguyên đó đang được xử dụng và “hạ cờ xuống” khi hoàn thành xử lý trên tài nguyên đó. Các luồng xử lý khác bằng việc quan sát trạng thái của cờ này mà sẽ chiếm tài nguyên cho xử lý của mình, hay chờ đợi cho đến khi luồng xử lý khác kết thúc. Có thể nói lock là một phương tiện khẳng định quyền sở hữu đối với 1 loại tài nguyên. Nhờ cơ chế lock này mà tại mỗi thời điểm chỉ có duy nhất 1 luồng xử lý truy cập tài nguyên dùng chung..

Shared lock, hay còn gọi là read-only lock (khóa chỉ đọc) là lock mà một luồng xử lý phải chiếm hữu khi muốn đọc từ một vùng nhớ được chia sẻ.

Exclusive lock, hay còn gọi là read-write lock (khóa đọc ghi) là lock mà một luồng xử lý phải chiếm hữu khi muốn cập nhật một vùng nhớ được chia sẻ.

2.2 Tại sao phải cần lock

a. Tác dụng của exclusive lock

Xét bài toán ta có 2 luồng xử lý A, B cùng thao tác trên 2 biến a, b. Giá trị hiện tại của các biến

  • a = 0
  • b = 0

Nhiệm vụ của 2 luồng xử lý:

  • Luồng xử lý A đọc giá trị 2 biến, tăng giá trị của a lên 1, và cập nhật giá trị cả 2 biến vào bộ nhớ.
  • Luồng xử lý B đọc giá trị 2 biến, tăng giá trị của b lên 1, và cập nhật giá trị cả 2 biến vào bộ nhớ.

Giả sử A thực hiện trước, đọc giá trị của a và b (a=0, b=0) và tăng a lên 1. Lúc này giá trị của a đang là 1, của b là 0. Tuy nhiên, chính tại thời điểm này, hệ điều hành quyết định dừng A, và thực hiện B. B đọc giá trị 2 biến a và b và tăng b lên 1 rồi cập nhật cả 2 biến vào bộ nhớ. Do A chưa cập nhật giá trị 2 biến, giá trị của a lúc này vẫn là 0, của b là 1 (a=0,b=1 do A chưa hoàn thành). Vì vậy, tại thời điểm B kết thúc a = 0, b = 1. Đến đây hệ điều hành thực hiện chạy A ở bước cuối cùng, cập nhật giá trị 2 biến (a = 1, b = 0) vào bộ nhớ, ghi đè giá trị b = 1 được cập nhật bởi B. Kết quả cuối cùng (a = 1, b = 0) sai so với kết quả mong đợi (a = 1, b = 1).

    Luồng A            |  Luồng B
    -------------------|-------------------------
    Đọc a, b (a=0, b=0)|    
    a = 1, b = 0       |
                       |  Đọc a, b (a=0, b=0)
                       |  a = 0, b = 1
                       |  Ghi a, b (a = 0, b = 1)
    Ghi a, b (a=1, b=0)|


Trạng thái cuối cùng (a=1, b=0)
Kết quả mong chờ: (a=1, b=1)

Để giải quyết bài toán này, người ta dùng một exclusive lock. Luồng A, B muốn cập nhật giá trị a, b phải trước tiên chiếm lock, rồi mới thực hiện xử lý của mình. Các bước thực hiện giờ sẽ như sau:

  • Chiếm lock. Nếu như chiếm được lock thì tiến hành xử lý tiếp theo. Nếu không, chờ đến khi chiếm được lock
  • Đọc a, b
  • Cập nhật a (hoặc b)
  • Ghi a, b
  • Thôi chiếm lock

Quy trình bây giờ tăng 2 bước (chiếm lock hoặc chờ đến khi nào chiếm được lock và nhả lock). Các bước chạy ở trên giờ sẽ như sau: A chiếm lock thành công (do chưa có luồn nào chiếm lock) và tiến hành đọc và cập nhật a (a=1, b=0). Tại thời điểm A bị dùng, B được chạy và thử chiếm lock nhưng do A đang nắm giữ lock nên B sẽ phải chờ. Lúc này A thực hiện nốt bước cuối cùng ghi giá trị vào bộ nhớ (a=1, b=0) và nhả lock. B lúc này thấy lock bị nhả và nắm giữ lock, đọc giá trị từ bộ nhớ (a=1, b=0!!), cập nhật b (a=1, b=1) và ghi dữ liệu vào bộ nhớ (a=1, b=1) đồng thời nhả lock. Kết quả cuối cùng như mong đợi (a=1, b=1) Luồng A | Luồng B ——————–|——————————– Chiếm lock | Đọc a, b |
a = 1, b = 0 | | Chiếm lock (không thành công) Ghi a, b (a=1, b=0) | Nhả lock | | Chiếm lock (thành công) | Đọc a, b (a=1, b=0) | a = 1, b = 1 | Ghi a, b (a = 1, b = 1) | Nhả lock

Trạng thái cuối cùng (a=1, b=1)
Kết quả mong chờ: (a=1, b=1)!!!!

Ta có thể thấy nhờ có lock, mà tại mỗi thời điểm chỉ có 1 luồng xử lý truy cập a, b. Các luồng khác khi không chiếm được lock sẽ phải chờ đến lượt.

Kết luận: exclusive lock được sử dụng để đồng bộ 2 xử lý ghi đồng thời.

b. Tác dụng của shared lock

Để hiểu ý nghĩa shared lock, ta xét bài toán tương tự như ở trên. Lần này, thay vì 2 luồng cùng cập nhật vùng nhớ chung, 1 luồng sẽ đọc, còn một luồng sẽ cập nhật. Giả sử A đọc và B cập nhật, và giá trị ban đầu là a=0, b=0.

A tiến hành đọc a (a=0) và bị hệ điều hành cho nghỉ để B thực hiện. B đọc a, b (a=0, b=0) và thực hiện cập nhật b (a=0, b=1) và ghi kết quả lên bộ nhớ. A đọc nốt giá trị còn lại b (b=1).

Kết quả cuối cùng đối với A rõ ràng không như mong đợi (Mong chờ: a=0, b=0 nhưng nhận về a=0, b=1).

    Luồng A     | Luồng B
    ------------|-------------------------
    Đọc a (a=0) |
                | Đọc a, b (a=0, b=0)
                | a = 0, b = 1
                | Ghi a, b (a = 0, b = 1)
    Đọc b (b=0) |

Kết quả cuối cùng (a=0, b=1)
Kết quả mong chờ: (a=0, b=0)

Để giải quyết bài toán này, ta dùng shared-lock. Luồng A muốn đọc sẽ cần phải chiếm shared-lock. Luồng B muốn ghi sẽ phải nhìn xem shared-lock có bị chiếm hay không. Nếu shared-lock đang bị chiếm. B phải đợi cho đến khi tất cả shared-lock được giải phóng mới tiến hành cập nhật. Luồng chạy sau bây giờ sẽ như sau:

A chiếm shared-lock và tiến hành đọc a (a=0). A được cho nghỉ và B được đưa vào sân. Do A đang chiếm shared lock nên B không được quyền cập nhật và được cho nghỉ. A thực hiện nốt việc đọc b (b=0) và nhả shared-lock. B không thấy shared-lock và tiến hành cập nhật b (b=1) như bình thường. Luồng A | Luồng B ——————|—————————– Chiếm shared-lock | Đọc a (a=0) | | Nhìn thấy shared-lock. Nghỉ Đọc b (b=0) | Nhả shared-lock | | Chiếm exclusive lock | Đọc a, b (a=0, b=0) | a = 0, b = 1 | Ghi a, b (a = 0, b = 1) | Nhả exclusive lock.

Kết quả cuối cùng (a=0, b=0)
Kết quả mong chờ: (a=0, b=0)

Kết luận: shared-lock được sử dụng để đồng bộ 2 xử lý đọc ghi đồng thời.

c. Tại sao cần chiếm shared lock khi đọc dữ liệu

Đến đây ta có thể dễ dàng trả lời câu hỏi thứ nhất của kiennt! Khi tiến hành đọc dữ liệu, nếu không chiếm shared-lock, dữ liệu đó có thể bị cập nhật bởi 1 thread khác, dẫn tới giá trị đọc được bị thay đổi một phần, không bảo đảm tính toàn vẹn.

Tuy vậy có một điểm trong bài viết cần được làm rõ như sau: Trong câu nói “MyISAM lock toàn bộ table. User (MySQL server) chiếm shared-lock khi đọc và chiếm exclusive-lock khi ghi. Tuy vậy, việc đọc ghi có thể diễn ra đồng thời!”, thì câu nói: “Tuy vậy, việc đọc ghi có thể diễn ra đồng thời!” là không đúng.

Nói một cách chính xác hơn: việc đọc và cập nhật (SELECT và UPDATE) không được tiến hành đồng thời (Do tại thời điểm chiếm exclusive lock, không shared-lock nào được phép tồn tại). Tuy nhiên, việc ghi mới dữ liệu có thể được tiến hành mà không gây ảnh hưởng gì đến tính toàn vẹn của dữ liệu được đọc (vì chúng khác nhau!). Nói cách khác việc đọc và ghi mới (SELECT và INSERT) có thể được tiến hành đồng thời, và thực tế đây cũng là một tính năng của MyISAM.

2.2 MVCC (Multiversion concurrency control)

MVCC được đặt ra để giải quyết vấn đề đọc và ghi đồng thời. Rõ ràng ở giải pháp shared-lock trình bày ở trên, việc A phải chiếm shared lock là rất mất thời gian; B phải chờ đợi A hoàn thành xử lý đọc mới được cập nhật cũng hoàn toàn không hiệu quả. MVCC giải quyết sự không hiệu quả này bằng nhận xét: Thao tác đọc không cập nhật dữ liệu vì vậy nếu ta duy trì 2 version của dữ liệu (1 cũ - 1 mới) và chỉ tiến hành ghi đè dữ liệu mới vào cũ khi A hoàn thành việc đọc, thì ta không cần shared-lock. B sẽ cập nhật và version mới thay vì phải chờ A hoàn thành việc đọc.

MVCC lưu timestamp và transaction ID để duy trì tính tính nhất quán của dữ liệu.

Để hiểu cách MVCC hoạt động, ta xét ví dụ sau (Copy từ Wikipedia :D) Thời gian | Object 1 | Object 2 —————-|—————|———— 0 | Foo | Bar 1 | Hello (T1) |

Tại thời điểm 0, Object 1 có giá trị Foo, Object 2 có giá trị Bar. Tại thời điểm 1, thread T1 cập nhật giá trị Object 1 thành Hello. Như vậy trước khi T1 commit dữ liệu, bất cứ xử lý đọc nào cũng cho kết quả Object 1 và 2 tại thời điểm 0 (Foo-Bar). Khi T1 commit dữ liệu, xử lý đọc sẽ trả về giá trị Hello-Bar.

Giả sử tại thời điểm T1 chưa commit, T2 lại cập nhật Object 2 như sau: Thời gian | Object 1 | Object 2 —————-|—————|———— 0 | Foo | Bar 1 | Hello (T1) | 2 | | World (T2)

Lúc này, mọi thao tác đọc sẽ vẫn trả về Foo-Bar. Khi T1 commit, dữ liệu đọc tiếp theo sẽ là Hello-Bar. Và khi T2 commit, dữ liệu đọc sẽ là Hello-World. Nói cách khác, tại mỗi thời điểm ta nhìn thấy 1 phiên bản dữ liệu, các cập nhật bởi thread khác sẽ được lưu tại một version khác.

Chính nhờ cách quản lý nhiều version dữ liệu này, mà việc đọc không cần lock shared-lock vẫn đảm bảo được tính toàn vẹn của dữ liệu được đọc.

3. Tổng kết

Bài viết đã giải thích phần nào hiểu hơn ý nghĩa của exclusive lock và shared-lock cũng như tác dụng và sự cần thiết của chúng. Đồng thời, bài viết cũng làm rõ những điểm được nói đến trong viết trong bài Giới Thiệu Một Số Storage Engine Của Mysql cũng như giải đáp những thắc mắc của bạn kiennt.

4. Tham khảo:

  1. MVCC
  2. Lock
  3. Exclusive lock & shared lock
  4. High performance MySQL
Comments

Scope và closure là gì?

Scope và closure là 2 khái niệm cơ bản mà một programmer nên biết, vì hiểu rõ 2 khái niệm này vừa giúp cho programmer tránh được một số lỗi hay gặp, vừa giúp thiết kế chương trình tốt hơn. Đầu tiên chúng ta sẽ remind lại 2 khái niệm này một cách ngắn gọn. Đầu tiên là khái niệm về scope, khái niệm này quá cơ bản chắc hẳn mọi người đều biết, nhưng thôi mình cứ quote lại từ wikipedia đề phòng có người quên: > [Scope refers to where variables and functions are accessible, and in what context it is being executed]

=> Dịch ra đại thể là scope là nơi mà biến hoặc hàm có thể truy cập vào và sử dụng/ tham chiếu được qua tên trực tiếp. Và ở ngoài scope đó thì biến hoặc hàm đó sẽ không thể nhìn được một cách trực tiếp nữa. (hơi khó hình dung nhỉ). Để phân loại scope thì có rất nhiều cách tùy thuộc vào từng góc nhìn , nhưng mình sẽ không đi sâu vào vấn đề này. Mỗi ngôn ngữ lại có đặc trưng về scope khác nhau. Trong bài viết này chúng ta sẽ chỉ tập trung vào javascript.

Khái niệm tiếp theo là về closure, khái niệm này thì không phải ai cũng biết, vì không phải ai cũng cần đến và từng động đến. Một số ngôn ngữ mainstream như C++ , java cũng không hỗ trợ closure, càng làm ít người để ý đến nó (java 8 expected sẽ cho closure vào). Hãy xem wiki nói về closure thế nào:

[a closure (also lexical closure or function closure) is a function or reference to a function together with a referencing environment]

=> Dịch ra đại thể là closure là một hàm hoặc một tham chiếu (hay còn gọi là một cái bao đóng) đi kèm với cái môi trường mà nó tham chiếu đến (khá là xoắn). Cái cần nhấn mạnh ở đây là cái referencing environment (môi trường tham chiếu) mà các bạn sẽ hiểu hơn ở các ví dụ dưới đây.

Scope và closure trong javascript

Javascript là một ngôn ngữ phổ biến hiện nay. Người biết về js thì nhiều, nhưng người hiểu rõ một số corner của js thì chắc không nhiều đến thế :D. Một trong các corner đấy chính là scope và closure. Js là một ngôn ngữ khá đặc biệt, đặc biệt ở chỗ js mang hơi hướng của lập trình hàm (functional programming), khi mà function ở js cũng là một first-class object, tức là function có thể được tạo mới (construct new) tại run-time, được lưu dưới dạng một cấu trúc dữ liệu (data structure), được truyền qua parameter, được dùng như một giá trị trả về (return value). Chính vì đặc điểm đấy khiến cho scope và closure của js không giống như các ngôn ngữ phổ biến khác.

Đầu tiên chúng ta sẽ nói về scope

1. Scope

Như chúng ta vừa nói ở trên, scope là khái niệm qui định “visibility” và “lifetime” của variable. Thông thường, ví dụ như C thì scope sẽ là block scope, tức là những biến tạo ra trong block sẽ chỉ được nhìn thấy trong block đấy thôi, và khi ra ngoài block đấy thì những variable nằm trong block sẽ được giải phóng ( như trong C là các biến tạo ra trong stack sẽ được free khi ra khỏi block), và không nhìn thấy được nữa.

Tuy nhiên rất buồn là javascript của chúng ta lại không có cái scope dễ hiểu đến thế, mà nó lại là function block.Function block ở đây là gì: tức là những gì bạn tạo ra trong một function sẽ available ở trong function đó. Vì javascript cũng là block syntax, nên sẽ hơi dễ confusing, chúng ta sẽ dùng ví dụ dễ hiểu này:

function.js
1
2
3
4
5
6
7
function scope() {
  if (true) {
    var test = 1;
  }
  alert(test); #=> 1
}
scope();

Nói đến đây chắc chắn có bạn sẽ nghĩ đến điều gì xảy ra khi chúng ta có nested function. Let’s try

function.js
1
2
3
4
5
6
7
8
function outer() {
  var outer_var = 2;
  function inner() {
    alert(outer_var);
  }
  inner();
}
outer(); #=> 2

Từ ví dụ trên ta có thể dễ dàng thấy là inner function có thể access được outer function variable. Từ ví dụ này chúng ta có thể thấy là inner function có thể inherit biến của outer function, hay nói cách khác, inner function chứa(contain) scope của outer function. Chính nhờ điều đặc biệt này mà chúng ta có cái gọi là Closure mà mình sắp sửa nói đến ngay dưới đây. Một điều chú ý là đối với nhiều ngôn ngữ thì các bạn hay được khuyên là declare biến muộn nhất có thể để tránh overhead, tuy nhiên với javascript là ngôn ngữ với function scope thì best practice lại là declare biến sớm nhất có thể để tránh nguy cơ xảy ra một số lỗi không mong muốn.

2. Closure

Quote lại cái định nghĩa cho đỡ quên:

A closure is an expression (typically a function) that can have free variables together with an environment that binds those variables (that “closes” the expression).

Chắc có bạn sẽ thắc mắc, environment ở đây là gì. Để hình dung một cách dễ hiểu, thì environment ở đây trong phần lớn các trường hợp chính là cái outer function mà chung ta vừa thử ở ví dụ về scope ở trên. Một đặc điểm rất hay của closure là closure sẽ giữ tham chiếu đến các biến nằm bên trong nó, hoặc được gọi đến bên trong nó. Điều này dẫn đến việc gì? Chắc sẽ có bạn nghĩ đến một trường hợp rất đặc biệt là khi bạn muốn context của một function được giữ lại sau khi hàm đó đã được execute xong :D. Hãy bắt đầu bằng một ví dụ:

closure.js
1
2
3
4
5
6
7
8
9
10
function outside(x) {
  function inside(y) {
    return x + y;
  }
  return inside;
}
fn_inside = outside(3);
result = fn_inside(5); // #=> 8

result1 = outside(3)(5); // #=> 8

Bạn có nhận thấy điều gì đặc biệt ở trên? Điều đặc biệt nằm ở hàm fn_inside : hàm fn_inside được tạo ra bởi kết quả trả về của hàm outside() với parameter là 3, và bạn có thể nhận thấy hàm fn_inside vẫn giữ tham chiếu đến cái parameter 3 đó ngay cả khi hàm outside() đã được execute xong. Chắc các bạn sẽ thấy mâu thuẫn với cái lý thuyết về function scope chúng ta đã nói đến ở trên, khi mà mọi thứ được tạo ra trong function của js chỉ nhìn thấy và sử dụng được ở trong đó, và sẽ được giải phóng hoặc không nhìn thấy khi ra ngoài function đó. Thực tế là không hề mâu thuẫn chút nào cả, chính vì cái gọi là closure của js :D. Nói một cách cụ thể hơn: fn_inside khi được tạo ra đã đồng thời cũng tạo ra một cái closure (bao đóng), trong cái bao đó, giá trị 3 được truyền vào, và cái bao của fn_inside sẽ vẫn giữ cái giá trị 3 đó cho dù outside() function có execute xong. Các bạn cứ hình dung trực quan closure như một cái bao chứa rất nhiều thứ trong nó là sẽ thấy dễ hiểu hơn:

Như vậy chúng ta có thể tóm gọn lại đoạn code ở trên như sau:

  1. Khi outside() được gọi, outside trả về một function
  2. function được outside trả lại (fn_inside) đó đóng lại cái context hiện tại và cái context đó chứa biến x tại thời điểm outside() được gọi
  3. Khi fn_inside được gọi, nó vẫn nhớ x có giá trị là 3
  4. Khi invoke fn_inside(5) thì nó sẽ lấy giá trị biến y=5 + giá trị biến x=3 và kết quả sẽ là 8

Như vậy chúng ta có thể rút ra một đặc điểm của closure là: > A closure must preserve the arguments and variables in all scopes it references

  • Một câu hỏi được đặt ra là: Khi nào cái biến x được giải phóng?? Câu trả lời là khi mà cái context mà biến x được reference đến ( ở đây là fn_inside ) không còn accessible được nữa ( refer đến scope của js, chúng ta có thể hiểu là khi mà function chứa fn_inside được execute xong và không còn bất kì tham chiếu nào đến fn_inside nữa ).

  • Một câu hỏi khác được đặt ra là với multi-nested function thì điều gì sẽ xảy ra?? Let’s give a try:

multinested.js
1
2
3
4
5
6
7
8
9
10
function A(x) {
  function B(y) {
    function C(z) {
      alert(x + y + z);
    }
    C(3);
  }
  B(2);
}
A(1); #=> 6

Ở đoạn code trên thì điều gì đã xảy ra?

  1. B tạo ra một cái closure chữa context của A, do đó B có thể access vào A’s variable, ở đây là x
  2. C tạo ra một cái closure chứa context của B
  3. Vì B chứa context của A nên C cũng sẽ chứa context của A, tức là C cũng access được vào biến x của A, và cả biến y của B.

Do đó kết quả sẽ là 1+2+3=6, khá là obvious nhỉ. Đoạn code ở trên giúp chúng ta có thêm một khái niệm mới gọi là scope chaining. Tại sao gọi là chaining, vì khi context được include từ outer function vào inner function, thì chúng ta sẽ hiểu một cách đơn giản là context của inner function và context của outer function được nối với nhau, một cách có chiều (directed). Và độ ưu tiên khi access biến là từ trong ra ngoài.

Do cái scope chaining là directed nên ở phía ngược lại, A lại không thể access được C, vì C nằm trong context của B, và chỉ visible inside B, hay nói cách khác là C sẽ là private của B, và không nhìn được từ A.

  • Lại có một bạn nghĩ là khi outer function có biến tên là x, mà ta cũng truyền 1 biến tên là x vào inner function, tức là khi có name-conflict thì chuyện gì sẽ xảy ra. Let’s take an example
nameconflict.js
1
2
3
4
5
6
7
8
function outside() {
  var x = 10;
  function inside(x) {
    return x;
  }
  return inside;
}
result = outside()(20); #=> 20

Bạn có thể thấy kết quả trả về biến x được trực tiếp truyền vào inner function thay vì biến x của outer function. Sử dụng khái niệm scope chaining ở trên thì chúng ta có thể thấy độ ưu tiên của context inside là cao hon context outside khi intepreter tìm giá trị của x, nên giá trị của x ở inside (ở đây là 20) sẽ được sử dụng.

Hy vọng là với 3 ví dụ trên các bạn đã có cái nhìn rõ ràng hơn về closure.

3. Closure pitfalls

Closure là một khái niệm khá dễ nhầm lẫn và khó nắm rõ với những người người ít quan tâm đến javascript. Một trong những ví dụ hay được dùng để minh họa cái sự dễ nhầm này được gọi là The Infamous Loop Problem. Ví dụ này được minh họa bằng đoạn code dưới đây:

closurepitfall.js
1
2
3
4
5
6
7
8
9
10
var add_the_handlers = function (nodes) {
  var i;
  for (i = 0; i < nodes.length; i += 1) {
    nodes[i].onclick = function (e) {
      alert(i);
    };
  }
};
nodes = document.getElementById("click");
add_the_handlers(nodes);

Đoan code ở trên làm một việc là tìm tất cả các node có id là “click”, add vào node đó một cái sự kiện là khi click vào node đó sẽ alert lên thứ tự của node đó. Giả sử bạn có một file html như sau:

closurepitfall.html
1
2
3
4
5
<li id="click">link 1 </li>
<li id="click">link 2 </li>
<li id="click">link 3 </li>
<li id="click">link 4 </li>
<li id="click">link 5 </li>

Bạn hy vọng là khi click vào link 1 sẽ alert 1, click vào link 2 sẽ alert ra 2…. đúng không. Tuy nhiên thực tế là bạn click vào link nào nó cũng alert ra 5 cả. Kì lạ nhỉ? Để giải thích cho hiện tượng này thì chúng ta hãy xem lại khái niệm về closure nào. Biến i được sử dụng trong anonymous function được gán cho onclick, được kế thừa từ context của add_the_handlers function. Tại thời điểm mà bạn gọi onclick, for loop đã được execute xong, và biến i của context của add_the_handlers lúc này có giá trị là 5. Do đó bạn có click vào link nào thì giá trị được alert ra cũng là 5 cả. Điểm chú ý của việc này chính là do bạn đang nhầm lẫn, hay chính xác là có sự khác biệt giữa scope/context của for-loop với scope/context của outer function là add_the_handlers .

Để giải quyết vấn đề này thì bạn có thể làm như dưới đây:

pitfall.js
1
2
3
4
5
6
7
8
9
10
11
var add_the_handlers = function (nodes) {
  var helper = function (i) {
    return function (e) {
      alert(i);
    };
  };
  var i;
  for (i = 0; i < nodes.length; i += 1) {
    modes[i].onclick = helper(i);
  }
};

Point của cách làm này chính là việc truyền được giá trị của (i) tại thời điểm hiện tại vào closure của function được bind (gán) vào onclick. Giúp cho hàm helper() luôn tham chiếu đến giá trị i đúng. Một best practice để tránh những sai lầm như thế này là > Avoid creating functions within a loop. It can be wasteful computationally,and it can cause confusion (tránh tạo mới function trong vòng loop, vì nó vừa làm tốn tài nguyên cpu, vừa dễ gây nhầm lẫn)

Kết luận

Như vậy qua bài viết này chúng ta đã nắm được khái niệm về function scope và closure trong javascript, và một số best practices trong việc sử dụng closure và scope. Closure trong javascript hay sử dụng để tạo ra một cái bao mà các thứ trong đấy không được nhìn thấy bởi bên ngoài nhưng vẫn truy cập được từ bên trong, và thường được áp dụng cho một số design pattern trong js (tiêu biểu nhất là module pattern).Chi tiết hơn các bạn có thể tham khảo ở các tài liệu sau:

Comments

Mở đầu

Trong lập trình nhúng (embedded system), ta rất thường hay gặp khai báo biến với từ khóa volatile. Việc khai báo biến volatile là rất cần thiết để tránh những lỗi sai khó phát hiện do tính năng optimization của compiler. Trong bài viết này, ta sẽ tìm hiểu ý nghĩa của từ khóa này, cách sử dụng nó và giải thích tại sao nó quan trọng trong một số trường hợp lập trình với hệ thống nhúng và lập trình ứng dụng đa luồng.

Tại sao cần phải có biến volatile

Cách khai báo biến với từ khóa volatile:

1
2
3
4
5
volatile int foo;//both this way...
int volatile foo;//... and this way is OK! Define a volatile integer variable

volatile uint8_t *pReg;//both this way...
uint8_t volatile *pReg;//... and this way is OK! Define a pointer to a volatile unsigned 8-bit integer

Một biến cần được khai báo dưới dạng biến volatile khi nào? Khi mà giá trị của nó có thể thay đổi một cách không báo trước. Trong thực tế, có 3 loại biến mà giá trị có thể bị thay đổi như vậy:

  • Memory-mapped peripheral registers (thanh ghi ngoại vi có ánh xạ đến ô nhớ)
  • Biến toàn cục được truy xuất từ các tiến trình con xử lý ngắt (interrupt service routine)
  • Biến toàn cục được truy xuất từ nhiều tác vụ trong một ứng dụng đa luồng.

Thanh ghi ngoại vi

Trong các hệ thống nhúng thường có các thiết bị ngoại vi (ví dụ như cổng vào ra đa chức năng GPIO, cổng UART, cổng SPI, …), và các thiết bị ngoại vi này chứa các thanh ghi mà giá trị của nó có thể thay đổi ngoài ý muốn của dòng chương trình (program flow). Ví dụ một thanh ghi trạng thái pStatReg, ta cần phải thực hiện polling thanh ghi trạng thái này đến khi nó khác 0 (Đoạn code minh họa với Keil ARM compiler, trên vi điều khiển ARM LPC2368)

mappedIOi_nonvolatile.c
1
2
3
unsigned long * pStatReg = (unsigned long*) 0xE002C004;
//Wait for the status register to become non-zero
while(*pStatReg == 0) { }

Đoạn code này có gì không ổn? Nó sẽ chạy sai khi ta bật chức năng tối ưu (optimization) của compiler. Quan sát mã assembly mà compiler xuất ra của đoạn code trên như sau:

mappedIO_nonvolatile.s
1
2
3
4
5
6
7
8
9
10
        LDR      r0,|L2.564|
        SUB      sp,sp,#0x10
        LDR      r0,[r0,#0]
|L2.22|
        CMP      r0,#0
        BEQ      |L2.22|
        LDR      r1,|L2.564|
...
|L2.564|
        DCD      0xe002c004

Trước khi vào label |L2.22|, tương ứng với vòng lặp while, thanh ghi r0 được ghi vào giá trị được lưu trong ô nhớ 0xE002C004. Khi vào vòng lặp while, compiler thực hiện ngay việc so sánh giá trị của thanh ghi r0 với 0. Tại sao lại như vậy? Vì compiler nhận thấy biến pStatReg là một biến normal, giá trị của nó được hiểu là không thể thay đổi một cách bất thường. Do vậy, khi bật tối ưu, compiler sẽ chỉ thực hiện so sánh giá trị của r0 mà không load lại giá trị này từ ô nhớ 0xE002C004, vì theo flow của chương trình thì biến pStatReg không bị thay đổi ở bất cứ đâu. Do đó, vòng lặp while sẽ chạy vô tận, hoặc không chạy gì cả (tùy theo giá trị ban đầu mà pStatReg trỏ đến).

Điều gì sẽ xảy ra nếu ta đổi biến pStatReg sang volatile?

mappedIO_volatile.c
1
2
3
volatile unsigned long * pStatReg = (unsigned long*) 0xE002C004;
//Wait for the status register to become non-zero
while(*pStatReg == 0) { }

Mã assembly mà compiler xuất ra sẽ như sau:

mappedIO_volatile.s
1
2
3
4
5
6
7
8
9
10
    SUB      sp,sp,#0x10
        LDR      r0,|L3.544|
|L3.4|
        LDR      r1,[r0,#0]
        CMP      r1,#0
        BEQ      |L3.4|
        LDR      r1,|L3.544|
...
|L3.544|
        DCD      0xe002c004

Điều gì khác biệt ở đây? Đầu tiên, ở dòng LDR trước label |L3.4|, compiler đã đặt địa chỉ 0xE002C004 vào thanh ghi r0. Ở dòng LDR đầu tiên ngay sau label |L3.4|, ta thấy compiler đã LOAD LẠI GIÁ TRỊ của ô nhớ 0xE002C004 vào ô nhớ r1! Sau đó nó mới thực hiện so sánh giá trị của ô nhớ r1 này với 0.

Lý do là gì? Vì ta đã đặt biến pStatReg là biến volatile, để báo hiệu là biến này có thể thay đổi một cách bất thường, ngoài flow của chương trình. Do vậy nên, để “đề phòng”, compiler lúc nào cũng phải load lại giá trị mới của ô nhớ 0xE002C004, để đảm bảo mình có giá trị mới nhất!

Đến đây, bạn có thể hỏi là “giá trị bị thay đổi một cách bất thường” là như thế nào? Hiện tượng này đặc biệt hay xảy ra khi lập trình nhúng. Trong hệ thống nhúng, một thanh ghi có thể bị thay đổi giá trị do những điều kiện bên ngoài. Ví dụ như mức điện áp không vượt quá ngưỡng, làm cho giá trị 0 thành 1, 1 thành 0. Hoặc, khi cổng UART nhận được đầy buffer thì thanh ghi BUFFER_READY tự động chuyển 0 thành 1… Bằng cách sử dụng biến volatile, chương trình C được compiler biên dịch sẽ đảm bảo luôn luôn đọc lại giá trị của thanh ghi, tránh mọi assumption của compiler.

Tiến trình con xử lý ngắt (Interrupt Service Routine)

Ngắt là một khái niệm quan trọng trong hệ thống nhúng. Có nhiều loại ngắt khác nhau như ngắt vào ra (I/O), ngắt SPI, ngắt UART… Mỗi khi xảy ra ngắt, stack pointer sẽ nhảy đến chương trình con xử lý ngắt (ISR). Thường thì các chương trình con xử lý ngắt này sẽ thay đổi giá trị của biến toàn cục và trong chương trình chính sẽ đọc những giá trị này để xử lý.

Để dễ hiểu, lấy ví dụ ngắt cổng serial (UART) kiểm tra các kí tự nhận được xem có phải là 0xFF không. Nếu là kí tự 0xFF, ISR sẽ set một biến cờ toàn cục. Nếu không có volatile, code như sau:

isr.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int etx_rcvd = FALSE;

void main()
{
  ...
  while(!ext_rcvd)
  {
      //Wait...
  }
  ...
}

interrupt void rx_isr(void)
{
  ...
  if(0xFF == rx_char)
  {
      ...
      etx_rcvd = TRUE;
  }
  ...
}

Ta để ý trong main(): compiler không thể biết được là biến ext_rcvd có thể bị thay đổi trong ISR. Compiler dò đoạn code, thấy rằng biểu thức !ext_rcvd luôn đúng, vì thế không thể thoát được vòng lặp while. Nếu compiler được bật optimization lên, tất cả đoạn code sau vòng lặp while sẽ bị loại bỏ. Nếu không có warning cẩn thận, chương trình của ta có thể bị lỗi mà phát hiện rất khó.

Giải pháp là đặt biến ext_rcvd là biến volatile. compiler sẽ biết đó là biến có thể bị thay đổi theo một cách nào đó ngoài ý muốn (ở đây là do ISR). Compiler sẽ bị buộc phải check giá trị của biến ext_rcvd.

Ứng dụng đa luồng

Trong các ứng dụng đa luồng, thường xảy ra trường hợp các tác vụ trao đổi thông tin với nhau thông qua một biến toàn cục. Như vậy, một tác vụ thay đổi giá trị của biến toàn cục cũng sẽ giống như trường hợp ISR ở trên. Nếu compiler mà bật tính năng optimization thì sẽ xảy ra vấn đề.

Ví dụ đoạn code

multithreaded.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int cntr;

void task1(void)
{
  cntr = 0;
  
  while(cntr == 0)
  {
      sleep(1);
  }
  //code follows...
}

void task2(void)
{
  //...code...
  cntr ++;
  sleep(10);
  //...code...
}

Cách khắc phục vấn như cũ: đặt biến cntr thành biến volatile !

Copyright © 2015 kỹ thuật máy tính