Liệu có dễ dàng?

Nhắc đến đa luồng là đã có nhiều người ngán ngẩm rồi nhỉ? Nào thì race condition, nào thì deadlock, nào thì thread safe, ... Nên câu trả lời chắc chắn là không dễ dàng rồi. Hơn thế nữa với mỗi một bài toán thì chúng ta lại phải sử dụng các kỹ thuật khác nhau để vừa đảm bảo tính đúng đắn vừa đảm bảo hiệu năng cho chương trình, đây là một chủ để rộng lớn và chắc cũng phải đến hàng triệu bài viết nói về đa luồng rồi. Chính vì thể trong phạm vi bài viết này mình sẽ nói đến trường hợp của ezyfox-server thôi nhé.

Vì sao chọn đa luồng?

ezyfox-server lựa chọn đa luồng vì bản thân nó vốn dĩ đã là đa luồng rồi, các luồng đó bao gồm:

  • Luồng đọc dữ liệu từ socket
  • Luồng xử lý sự kiện và request từ client
  • Luồng ghi dữ liệu đến socket

Thêm vào nữa nó lại phục 3 giao thức khác nhau:

  • TCP: dùng để xác thực và trao đổi các message cần độ tin cậy cao
  • Websocket: cũng giống như TCP nhưng chuyên dành cho web
  • UDP: dùng trao đổi các message không cần độ tin cậy cao hoặc các message cần truyền tải liên tục

Chính vì vậy single thread là không đủ và không khả thi. Tuy nhiên bây giờ nó sẽ phải giải quyết 2 bài toán hóc búa đó là race condition và deadlock.

Race condition

Tương tranh (race condition) là một tình huống mà nhiều luồng cùng muốn cập nhật trạng thái của tài nguyên dùng chung. EzyFox Server không cố gắng giải quyết vấn đề này mà nó cố gắng né tránh, vậy nó dùng những cách nào để né tránh? Mình sẽ nêu 2 trong nhiều kỹ thuật mà EzyFox Server đang sử dụng đó là:

  1. Sử dụng Stateless object
  2. Phân luồng xử lý

Stateless object

Đây là các đối tượng không chứa, hoặc chỉ chứa các thuộc tính chỉ đọc, ví dụ đối tượng EzySocketResponseApi dùng để response dữ liệu đến client này, nó sẽ chỉ chứa các hàm và trường encoder dùng để chuyển đối tượng thành dạng byte[] mà thôi.

public class EzySocketResponseApi extends EzyAbstractResponseApi {

    protected final EzyMessageDataEncoder encoder;

    public EzySocketResponseApi(Object encoder) {
        this.encoder = new EzySimpleMessageDataEncoder((EzyObjectToByteEncoder)encoder);
    }

    @Override
    protected Object encodeData(EzyArray data) throws Exception {
        return encoder.encode(data);
    }
}

Phân luồng xử lý

Bản thân dữ liệu mặc dù có thể đến từ nhiều luồng và đi ra nhiều luồng nhưng khi xử lý thì sẽ được quy hoạch vào các channel khác nhau, mà mỗi channel chỉ có 1 thread thành ra tổng thể chương trình là đa luồng, nhưng khi xử lý lại chỉ là đơn luồng, nó sẽ giúp chúng ta đỡ phải sử dụng lock hay synchronize. Ví dụ lớp EzySocketDataReceiver này sẽ chịu trách nhiệm điều phối dữ liệu từ socket đến các channel khác nhau.

Ví dụ, các luồng đọc dữ liệu từ socket sẽ tranh nhau sử dụng đối tượng session để ghi dữ liệu vào session chẳng hạn, thì code sẽ phải thế này:

synchronize(session) {
    session.received(byte[]);
}

Nhưng khi được điều phối vào các channel, thì dữ liệu sẽ được rơi vào đúng channel có chứa session cần nhận dữ liệu, mà channel là đơn luồng, nên chúng ta chỉ cần:

session.received(byte[]);

Mọi thứ đã rõ ràng và đơn giản hơn rất nhiều đúng không.

Deadlock

Deadlock là hiện tượng hai hay nhiều luồng chờ đợi lẫn nhau kết thúc, bạn có thể xem ví dụ này để thấy rõ hơn nhé. Theo wiki thì:

Có bốn điều kiện cần thiết để deadlock có thể xảy ra
  1. Điều kiện loại trừ lẫn nhau: Một tài nguyên không thể sử dụng bởi nhiều hơn một tiến trình tại một thời điểm
  2. Điều kiện giữ và chờ: Các tiến trình giữ tài nguyên và chờ tài nguyên mới
  3. Điều kiện không có trưng dụng tài nguyên: Các tài nguyên không thể bị đòi lại, chúng chỉ có thể được giải phóng bởi chính tiến trình chiếm giữ chúng
  4. Điều kiện chờ đợi vòng tròn: Các tiến trình giữ tài nguyên và chờ các tài nguyên bị giữ bởi tiến trình khác, tạo thành một chu trình. Ví dụ: Tiến trình 1, chiếm A1, chờ A2. Tiến trình 2 chiếm A2, chờ A3,... Tiến trình N chiếm An, chờ A1
Và các cách đối phó với deadlock bao gồm:
  1. Phòng tránh deadlock: dự đoán trước deadlock có xảy ra hay không trước khi tiến hành phân phối tài nguyên cho tiến trình. Ví dụ: giải thuật nhà băng (Banker's algorithm).
  2. Ngăn chặn deadlock: ngăn chặn ít nhất 1 trong 4 điều kiện để xảy ra deadlock nêu trên. Chẳng hạn: cho phép chia sẻ tài nguyên, cho phép trưng dụng,...
  3. Phát hiện và khắc phục deadlock: nếu không thể phòng tránh hay ngăn chặn deadlock, cứ để deadlock xảy ra và ta sẽ phát hiện và đi khắc phục chúng. Phuơng pháp này phù hợp với hệ thống ít xảy ra deadlock và hậu quả của deadlock là ít nghiêm trọng.

Mình đã từng chứng kiến 1 socket server khi bị deadlock sẽ hoạt động ngáo đá như thế nào rồi, nên bằng mọi cách EzyFox Server không được phép cho deadlock xảy ra, vậy nên nó cần phải phòng tránh deadlock và ngăn chặn deadlock xảy ra.

Phòng tránh deadlock

Đầu tiên phải xác định những tài nguyên nào bị chia sẻ và xung đột nào có thể xảy ra, nổi bật nhất sẽ là:

  • Danh sách session: sẽ bị các luồng khởi tạo, thanh tra, ngắt kết nối, xử lý sự kiện, xứ lý request tranh chấp
  • Danh sách user: sẽ bị các luồng khởi tạo, thanh tra, xoá bỏ, xử lý sự kiện, xứ lý request tranh chấp
  • Các đối tượng xử lý dữ liệu: Bị các luồng ghi dữ liệu và lấy ra request tranh chấp.
  • Các đối tượng zone, app, plugin hay các settings: gần như sẽ bị mọi luồng gọi đến.

Bằng cách phân luồng xử lý đã nói ở trên sẽ giải quyết được triệt để vấn đề này.

Ngăn chặn deadlock

Việc chia sẻ tài nguyên là không thể tránh khỏi, nên EzyFox Server cần cho phép chia sẻ tài nguyên một cách an toàn bằng cách đảm bảo tất cả các đối tượng được chia sẻ phải là ThreadSafe. Các đối tượng này nếu có thể là Stateless thì tốt, còn nếu không sẽ phải sử dụng đến Lock và Synchronized hay các đối tượng Atomic. Ví dụ như lớp EzySimpleSessionManager hay lớp EzySynchronizedUserManager này.

Bao nhiêu luồng là đủ?

Đây là câu trả lời khó, và không có câu trả lời cuối cùng, có người sẽ dùng 10 luồng, có người sẽ dùng 100 luồng, có người dùng cả nghìn luồng cũng không sao, miễn là vẫn đảm bảo hiệu năng là được. Tuy nhiên với EzyFox Server thì không phải vậy, EzyFox Server ra đời nhằm mục tiêu phục vụ cho mọi bài toán socket từ game, web, app, service cho đến IoT, nên nó cần sử dụng vừa đủ số luồng mà thôi.

Hiện tại nó sử dụng khoảng 50 luồng và con số này có thể tuỷ chỉnh lên xuống được (trong hình là 60 bao gồm cả các luồng của visualvm và java).

Vì sao EzyFox Server cần giới hạn số luồng? Mỗi luồng khi sinh ra đều sử dụng một số lượng tài nguyên nhất định, ví dụ mỗi luồng sẽ tốn 2MB RAM cho Stack Memory chẳng hạn, thì 100 luồng sẽ là 200MB, 1 nghìn luồng sẽ là 2GB, nó sẽ không phù hợp với các thiết bị có tài nguyên hạn chế hoặc các nhà phát triển cá nhân càn tiết kiệm chi phí, thêm vào nữa nhiều luồng chưa chắc đã nhanh hơn.

Vậy làm cách nào để chỉ với mốt số ít luồng EzyFox Server lại có thể phục vụ cho hàng nghìn hàng triệu request? Câu trả lời đến từ ExecutorService (hay các bạn vẫn thường gọi là ThreadPool) kết hợp với Queue và EvenLoop, nó sẽ cho chúng ta 1 công thức kì diệu, ví dụ như lớp EzySocketDataReceiver này chẳng hạn.

Tổng kết

Việc sử dụng đa luồng trong một socket server như ezyfox-server gần như không thể tránh khỏi, thế nên nói cũng phải đối mặt với rất nhiều thách thức, đặc biệt là các vấn đề liên quan đến việc chia sẻ tài nguyên. Chỉ một sai sót nhỏ cũng sẽ khiến cho cả server bị treo và sẽ rất khó để tìm ra nguyên nhân. Chính vì vậy phải sau 9 năm ấp ủ, xây dựng, kiểm thử và sử dụng thực tế mình mới có thể tự tin giới thiệu được đến cộng đồng.