Dựa trên các thành phần đã xây dựng trước đây, chúng ta sẽ tiến hành thiết kế một hồ luồng (thread pool). Trước khi bắt đầu viết mã, cần đảm bảo các điều kiện sau:
- Đã hoàn thiện việc đóng gói cơ bản cho luồng.
- Đã triển khai việc đóng gói cho khóa tương hỗ (mutex) và biến điều kiện (condition variable).
- Đã tích hợp hệ thống ghi nhật ký để cải thiện tính năng đóng gói luồng.
Với các chuẩn bị này, chúng ta sẽ bắt tay vào thiết kế hồ luồng.
1. Khái niệm về Hồ Luồng
Khái niệm cốt lõi và bối cảnh hình thành
Hồ luồng là một mô hình lập trình song song quản lý luồng dựa trên kỹ thuật nhóm (Pooling). Ý tưởng chính là: tạo sẵn một số lượng luồng nhất định, đưa chúng vào một "hồ" để quản lý tập trung. Khi có tác vụ cần thực thi, thay vì tạo một luồng mới, hệ thống sẽ lấy một luồng rảnh rỗi từ hồ để thực hiện. Sau khi tác vụ hoàn thành, luồng không bị hủy ngay mà trở lại hồ chờ thực hiện tác vụ tiếp theo.
Bối cảnh hình thành: Trong các mô hình song song trước đây, cách quản lý vòng đời luồng "tạo tức thì, hủy tức thì" tồn tại những hạn chế đáng kể:
- Tiêu tốn nhiều tài nguyên: Việc tạo và hủy luồng là các thao tác tốn kém, liên quan đến các cuộc gọi hệ điều hành, cấp phát bộ nhớ, khởi tạo tài nguyên. Thao tác thường xuyên sẽ tiêu tốn lượng lớn tài nguyên hệ thống.
- Độ trễ phản hồi cao: Khi một tác vụ đến, cần chờ luồng được tạo xong mới có thể thực thi, làm tăng thời gian phản hồi của tác vụ.
- Rủi ro về ổn định hệ thống: Việc tạo luồng không giới hạn có thể làm cạn kiệt tài nguyên hệ thống (như bộ nhớ, thời gian CPU). Mỗi luồng cần một lượng bộ nhớ nhất định (ví dụ: trong JVM, mỗi luồng có không gian stack riêng), nhiều luồng có thể dẫn đến tràn bộ nhớ (OOM). Hơn nữa, quá nhiều luồng chuyển đổi ngữ cảnh sẽ làm tăng tải CPU, khiến hiệu suất hệ thống giảm mạnh hoặc thậm chí sập.
Kỹ thuật hồ luồng đã giải quyết hoàn hảo các vấn đề trên thông qua việc tái sử dụng luồng, kiểm soát số lượng luồng song song và quản lý vòng đời tập trung, trở thành một thành phần thiết yếu cho các ứng dụng có yêu cầu về hiệu năng cao.
Ưu điểm và giá trị cốt lõi
- Giảm tiêu thụ tài nguyên: Tái sử dụng các luồng đã tạo, giảm đáng kể chi phí hệ thống do việc tạo và hủy luồng thường xuyên.
- Tăng tốc độ phản hồi: Khi tác vụ đến, không cần chờ tạo luồng, tác vụ có thể được thực thi ngay lập tức bởi luồng rảnh rỗi, giảm độ trễ.
- Nâng cao khả năng quản lý luồng: Luồng là tài nguyên quý giá. Nếu tạo không giới hạn, không chỉ tiêu tốn tài nguyên mà còn giảm sự ổn định của hệ thống. Hồ luồng cho phép phân bổ, điều chỉnh và giám sát luồng một cách thống nhất. Ví dụ, có thể kiểm soát số lượng luồng đồng thời tối đa, ngăn chặn tình trạng quá tải; có thể giám sát trạng thái hoạt động của luồng, quản lý hàng đợi tác vụ.
- Cung cấp các tính năng mạnh mẽ hơn: Hồ luồng cung cấp nhiều điểm mở rộng, ví dụ hỗ trợ thực thi các tác vụ định kỳ, trễ hoặc theo chu kỳ (như
ScheduledThreadPoolExecutor).
Các trường hợp ứng dụng điển hình của hồ luồng
1. Xử lý tác vụ ngắn với hiệu năng cao
Ví dụ điển hình: Xử lý yêu cầu máy chủ Web
- Đặc điểm: Thời gian xử lý từng yêu cầu ngắn (thường <100ms), nhưng số lượng yêu cầu rất lớn.
- Ưu điểm: Tránh chi phí tạo luồng mới cho mỗi yêu cầu.
- Trường hợp thực tế: Apache Tomcat mặc định sử dụng hồ luồng để xử lý các yêu cầu HTTP.
2. Ứng dụng yêu cầu tính thời gian thực cao
Ví dụ điển hình: Hệ thống giao dịch tài chính
- Đặc điểm: Yêu cầu độ trễ cực thấp (thường yêu cầu phản hồi <10ms).
- Ưu điểm: Các luồng trong hồ luôn ở trạng thái sẵn sàng, có thể xử lý tác vụ ngay lập tức.
- Cách triển khai: Thường kết hợp với hàng đợi công việc và cơ chế lập lịch ưu tiên.
3. Xử lý lưu lượng đột biến
Ví dụ điển hình: Hoạt động flash sale trên thương mại điện tử
- Đặc điểm: Số lượng yêu cầu tăng vọt trong thời gian ngắn (có thể gấp 100 lần bình thường).
- Ưu điểm: Ngăn chặn hệ thống quá tải bằng cách giới hạn số lượng luồng tối đa.
- Cơ chế bảo vệ: Khi yêu cầu vượt quá khả năng xử lý, có thể áp dụng chiến lược từ chối.
Các trường hợp không phù hợp:
- Tác vụ dài hạn (như phiên Telnet): Thời gian thực thi tác vụ vượt xa thời gian tạo luồng, ưu điểm của hồ luồng không rõ ràng.
Phân loại hồ luồng
1. Hồ luồng kích thước cố định (FixedThreadPool)
Nguyên lý hoạt động:
- Tạo ra một số lượng luồng cố định khi khởi tạo.
- Sử dụng một hàng đợi không giới hạn để lưu trữ các tác vụ chờ xử lý.
- Các luồng rảnh rỗi sẽ không bị thu hồi.
Kịch bản ứng dụng:
- Các trường hợp cần kiểm soát chặt chẽ việc sử dụng tài nguyên.
- Các dịch vụ chạy dài hạn với khối lượng tác vụ có thể dự đoán được.
- Ví dụ: Hồ kết nối cơ sở dữ liệu.
Đặc điểm:
- Ưu điểm: Triển khai đơn giản, tiêu thụ tài nguyên có thể kiểm soát được.
- Nhược điểm: Tác vụ tích tụ có thể dẫn đến tràn bộ nhớ.
2. Hồ luồng có khả năng mở rộng (CachedThreadPool)
Nguyên lý hoạt động:
- Không có luồng cốt lõi, số lượng luồng tối đa là
Integer.MAX_VALUE. - Sử dụng hàng đợi chuyển giao đồng bộ.
- Các luồng rảnh rỗi sẽ tự động bị thu hồi sau 60 giây.
Kịch bản ứng dụng:
- Thực thi nhiều tác vụ bất đồng bộ có vòng đời ngắn.
- Ví dụ: Các tác vụ tính toán song song.
Đặc điểm:
- Ưu điểm: Co giãn linh hoạt, thích ứng với lưu lượng truy cập đột biến.
- Nhược điểm: Số lượng luồng không giới hạn có thể dẫn đến cạn kiệt tài nguyên.
Trong bài viết này, chúng ta sẽ chọn loại hồ luồng với số lượng luồng cố định.
2. Triển khai Hồ Luồng
2.1. Khung Hồ luồng
Ở đây, chúng ta sẽ triển khai một hồ luồng với 5 luồng cố định.
#include <iostream>
#include <vector>
#include <string>
#include <queue>
#include <functional> // Cho std::function
#include <pthread.h> // Cho pthread_getname_np
// Giả định các file header Log.hpp, Cond.hpp, Thread.hpp, Mutex.hpp đã được định nghĩa
// với các lớp Log, Cond, Thread, Mutex tương ứng.
// Các namespace cũng được giả định có sẵn như trong bài gốc.
namespace MyThreadPool {
// Giả định các namespace sau đã được định nghĩa và có các lớp tương ứng:
// using namespace ThreadModule;
// using namespace LogModule;
// using namespace CondModule;
// using namespace MutexModule;
// Định nghĩa tạm thời các lớp Thread, Cond, Mutex, Log cho ví dụ này
// Trong thực tế, chúng sẽ được include từ các file header riêng
class Mutex {
public:
Mutex() { pthread_mutex_init(&_m, nullptr); }
~Mutex() { pthread_mutex_destroy(&_m); }
void lock() { pthread_mutex_lock(&_m); }
void unlock() { pthread_mutex_unlock(&_m); }
pthread_mutex_t* get_pthread_mutex() { return &_m; }
private:
pthread_mutex_t _m;
};
class LockGuard {
public:
LockGuard(Mutex& m) : _m(m) { _m.lock(); }
~LockGuard() { _m.unlock(); }
private:
Mutex& _m;
};
class Cond {
public:
Cond() { pthread_cond_init(&_c, nullptr); }
~Cond() { pthread_cond_destroy(&_c); }
void wait(Mutex& m) { pthread_cond_wait(&_c, m.get_pthread_mutex()); }
void signal() { pthread_cond_signal(&_c); }
void broadcast() { pthread_cond_broadcast(&_c); }
private:
pthread_cond_t _c;
};
enum LogLevel { DEBUG, INFO, WARNING, ERROR };
class Log {
public:
Log(LogLevel level) : _level(level) {}
template<typename T>
Log& operator<<(const T& msg) {
std::cout << msg;
return *this;
}
~Log() { std::cout << std::endl; }
private:
LogLevel _level;
};
// Helper macro for logging, assuming a global log strategy is enabled elsewhere
#define MY_LOG(level) Log(level)
class Thread {
public:
using ThreadFunc = std::function<void()>;
Thread(ThreadFunc func) : _threadFunc(func), _threadId(0), _name("unknown") {}
void Start() {
int ret = pthread_create(&_threadId, nullptr, Thread::ThreadWrapper, this);
if (ret == 0) {
char name_buf[128];
snprintf(name_buf, sizeof(name_buf), "worker-%lu", _threadId);
_name = name_buf;
pthread_setname_np(_threadId, _name.c_str());
MY_LOG(INFO) << "Thread " << _name << " started.";
} else {
MY_LOG(ERROR) << "Failed to create thread.";
}
}
void Join() {
if (_threadId != 0) {
pthread_join(_threadId, nullptr);
MY_LOG(INFO) << "Thread " << _name << " joined.";
}
}
std::string Name() const { return _name; }
private:
static void* ThreadWrapper(void* arg) {
Thread* self = static_cast<Thread*>(arg);
if (self) {
self->_threadFunc();
}
return nullptr;
}
ThreadFunc _threadFunc;
pthread_t _threadId;
std::string _name;
};
static const int kDefaultThreadCount = 5; // Số luồng mặc định
template <class T>
class ThreadPool {
public:
ThreadPool(int num = kDefaultThreadCount)
: _activeThreadCount(num), _isRunning(false), _waitingThreadCount(0)
{
for (int i = 0; i < _activeThreadCount; ++i) {
_workerThreads.emplace_back(
[this]() {
WorkerRoutine();
});
}
}
void WorkerRoutine() {
char thread_name[128];
pthread_getname_np(pthread_self(), thread_name, sizeof(thread_name));
while (true) {
T currentTask;
{
LockGuard lock(_queueMutex);
// Hàng đợi trống VÀ hồ luồng vẫn đang chạy, thì chờ
while (_pendingTasks.empty() && _isRunning) {
_waitingThreadCount++;
_taskAvailableCond.wait(_queueMutex);
_waitingThreadCount--;
}
// Sau khi được đánh thức, kiểm tra lại điều kiện thoát
if (!_isRunning && _pendingTasks.empty()) {
MY_LOG(INFO) << thread_name << " thoát. Hồ luồng muốn dừng và hàng đợi tác vụ rỗng.";
break;
}
// Lấy tác vụ từ hàng đợi
currentTask = _pendingTasks.front();
_pendingTasks.pop();
}
// Xử lý tác vụ bên ngoài critical section để không chặn các luồng khác
currentTask();
}
}
void Start() {
if (_isRunning) return;
_isRunning = true;
for (auto& thread : _workerThreads) {
thread.Start();
MY_LOG(INFO) << "Luồng mới khởi động: " << thread.Name();
}
}
void Stop() {
if (!_isRunning) return;
_isRunning = false; // Đặt cờ dừng
// Đánh thức tất cả các luồng đang chờ
NotifyAllWorkers();
}
void Join() {
for (auto& thread : _workerThreads) {
thread.Join();
}
}
bool AddTask(const T& task) {
LockGuard lock(_queueMutex);
if (_isRunning) {
_pendingTasks.push(task);
// Nếu có luồng đang chờ, đánh thức một luồng
if (_waitingThreadCount > 0) {
_taskAvailableCond.signal();
}
return true;
}
return false;
}
~ThreadPool() {
// Đảm bảo hồ luồng đã dừng và các luồng đã được join trước khi hủy
if (_isRunning) {
Stop();
Join();
}
}
private:
std::vector<Thread> _workerThreads; // Quản lý các luồng làm việc
int _activeThreadCount; // Số lượng luồng
std::queue<T> _pendingTasks; // Hàng đợi tác vụ
Cond _taskAvailableCond; // Biến điều kiện
Mutex _queueMutex; // Khóa tương hỗ cho hàng đợi
bool _isRunning; // Cờ trạng thái hoạt động
int _waitingThreadCount; // Số lượng luồng đang chờ
// Hàm đánh thức tất cả các luồng đang chờ
void NotifyAllWorkers() {
LockGuard lock(_queueMutex);
if (_waitingThreadCount > 0) {
_taskAvailableCond.broadcast();
MY_LOG(INFO) << "Đánh thức tất cả luồng.";
}
}
};
} // namespace MyThreadPool
Phân tích:
Các biến thành viên cốt lõi:
_workerThreads: Kiểustd::vector<Thread>, lưu trữ và quản lý các đối tượng luồng làm việc._activeThreadCount: Kiểu số nguyên, ghi lại số lượng luồng trong hồ luồng._pendingTasks: Kiểustd::queue<T>, làm hàng đợi tác vụ, lưu trữ các tác vụ chờ xử lý._taskAvailableCondvà_queueMutex: Biến điều kiện và khóa tương hỗ, dùng để đồng bộ hóa giữa các luồng và truy cập an toàn vào hàng đợi tác vụ.
Hàm tạo (Constructor): Hàm tạo nhận một tham số số nguyên num, biểu thị số lượng luồng trong hồ, giá trị mặc định là kDefaultThreadCount (5). Trong hàm tạo, chúng ta tạo ra num luồng và đặt hàm thực thi của mỗi luồng là WorkerRoutine (một hàm liên tục lấy tác vụ từ hàng đợi và thực thi). Ở đây, chúng ta sử dụng biểu thức lambda để bao bọc WorkerRoutine.
Hàm thành viên WorkerRoutine: Đây là hàm công việc của mỗi luồng. Nó liên tục lấy tác vụ từ hàng đợi và thực thi trong một vòng lặp vô hạn. Khi lấy tác vụ, luồng cần lấy khóa tương hỗ trước, sau đó kiểm tra xem hàng đợi tác vụ có trống không. Nếu trống, luồng gọi phương thức wait của biến điều kiện để chờ; nếu không, luồng lấy một tác vụ từ hàng đợi, sau đó giải phóng khóa (thông qua phạm vi của LockGuard), và tiếp tục thực thi tác vụ.
Lưu ý:
- Thực thi tác vụ (
currentTask()) bên ngoài vùng khóa để tránh tình trạng tác vụ thực thi quá lâu làm chặn các luồng khác.
Hàm Start: Khởi động tất cả các luồng. Duyệt qua vector luồng, gọi phương thức Start của mỗi luồng và ghi nhật ký.
Hàm Stop: Dừng hoạt động của hồ luồng bằng cách đặt cờ _isRunning về false và đánh thức tất cả các luồng đang chờ.
Hàm Join: Chờ tất cả các luồng trong hồ hoàn thành công việc và kết thúc. Điều này là cần thiết để tránh tài nguyên luồng bị rò rỉ (detached threads).
Đối với hàm thành viên WorkerRoutine, chúng ta có thể đặt nó ở chế độ private để không bị gọi từ bên ngoài, vì nó là một hàm nội bộ của hồ luồng.
2.2. Thoát khỏi Hồ Luồng
Chúng ta thêm một biến thành viên để làm cờ trạng thái hoạt động: true khi hồ luồng đang chạy, false khi dừng.
Hàm WorkerRoutine trong một vòng lặp vô hạn liên tục lấy và thực thi tác vụ. Khi muốn thoát, luồng có thể đang chờ tác vụ hoặc đang thực thi tác vụ. Khi hồ luồng dừng (_isRunning được đặt thành false), các tác vụ trong hàng đợi cần được xử lý hết. Đồng thời, các luồng đang chờ (trong trạng thái wait) cần được đánh thức để kiểm tra điều kiện thoát.
Nếu hàng đợi rỗng và hồ luồng dừng, các luồng sẽ thoát. Tuy nhiên, nếu tất cả các luồng đều đang ở trạng thái wait, việc đặt _isRunning = false sẽ không đủ để chúng thoát. Chúng cần được đánh thức. Vì vậy, trong hàm Stop, chúng ta gọi NotifyAllWorkers() để đánh thức tất cả các luồng đang ngủ. Trong WorkerRoutine, điều kiện thoát sẽ được kiểm tra lại sau khi được đánh thức.
Để theo dõi số lượng luồng đang ngủ, chúng ta thêm biến _waitingThreadCount. Hàm NotifyAllWorkers sẽ đánh thức tất cả các luồng đang chờ nếu có.
Sau khi các luồng thoát, cần join chúng để đảm bảo tài nguyên được giải phóng hoàn toàn, tương tự như việc chúng ta đã học trong phần kiểm soát luồng.
// Phần này đã được tích hợp vào mã nguồn ThreadPool ở trên.
// Dưới đây là phần code liên quan đến việc thoát hồ luồng trong WorkerRoutine và Stop.
// Trong hàm WorkerRoutine:
// while (_pendingTasks.empty() && _isRunning) {
// _waitingThreadCount++;
// _taskAvailableCond.wait(_queueMutex);
// _waitingThreadCount--;
// }
// if (!_isRunning && _pendingTasks.empty()) {
// MY_LOG(INFO) << thread_name << " thoát. Hồ luồng muốn dừng và hàng đợi tác vụ rỗng.";
// break;
// }
// Trong hàm Stop:
// void Stop() {
// if (!_isRunning) return;
// _isRunning = false;
// NotifyAllWorkers(); // Đánh thức tất cả luồng
// }
// Hàm NotifyAllWorkers:
// void NotifyAllWorkers() {
// LockGuard lock(_queueMutex);
// if (_waitingThreadCount > 0) {
// _taskAvailableCond.broadcast();
// MY_LOG(INFO) << "Đánh thức tất cả luồng.";
// }
// }
// Hàm Join:
// void Join() {
// for (auto& thread : _workerThreads) {
// thread.Join();
// }
// }
Kiểm tra thử nghiệm:
#include <unistd.h> // for sleep
#include <functional>
#include "MyThreadPool.hpp" // Thay thế cho ThreadPool.hpp và các dependencies
using namespace MyThreadPool;
// Giả định hàm Enable_Console_Log_Strategy(); được định nghĩa ở đâu đó
void Enable_Console_Log_Strategy() {
// Implement or mock this function if needed for full compilation
// For this example, MY_LOG directly prints to console.
}
int main() {
Enable_Console_Log_Strategy();
ThreadPool<std::function<void()>>* myThreadPool = new ThreadPool<std::function<void()>>();
myThreadPool->Start();
sleep(5); // Chờ 5 giây để các luồng khởi động và sẵn sàng
myThreadPool->Stop();
myThreadPool->Join();
delete myThreadPool; // Giải phóng bộ nhớ
return 0;
}
Kết quả chạy thử:
[2025-09-12 16:50:17] [INFO] Thread worker-123456789 started.
[2025-09-12 16:50:17] [INFO] Luồng mới khởi động: worker-123456789
... (tương tự cho các luồng khác) ...
[2025-09-12 16:50:22] [INFO] Đánh thức tất cả luồng.
[2025-09-12 16:50:22] [INFO] worker-123456790 thoát. Hồ luồng muốn dừng và hàng đợi tác vụ rỗng.
[2025-09-12 16:50:22] [INFO] worker-123456789 thoát. Hồ luồng muốn dừng và hàng đợi tác vụ rỗng.
... (tương tự cho các luồng khác) ...
[2025-09-12 16:50:22] [INFO] Thread worker-123456789 joined.
... (tương tự cho các luồng khác) ...
Kết quả cho thấy các luồng đã khởi động, chờ và sau đó thoát một cách có kiểm soát.
2.3. Đưa tác vụ vào hàng đợi
Tiếp theo là chức năng đưa tác vụ vào hàng đợi.
// Phần này đã được tích hợp vào mã nguồn ThreadPool ở trên.
// Dưới đây là phần code liên quan đến AddTask.
// bool AddTask(const T& task) {
// LockGuard lock(_queueMutex);
// if (_isRunning) {
// _pendingTasks.push(task);
// if (_waitingThreadCount > 0) {
// _taskAvailableCond.signal(); // Đánh thức một luồng
// }
// return true;
// }
// return false;
// }
Chúng ta tạo một tác vụ đơn giản để kiểm tra, tương tự như tác vụ từ ví dụ giao tiếp liên tiến trình.
#pragma once
#include <functional> // Cho std::function
// #include "Log.hpp" // Giả định MyThreadPool.hpp đã cung cấp MY_LOG
namespace MyThreadPool {
// Định nghĩa một kiểu tác vụ, trả về void, không tham số
using TaskFunction = std::function<void()>;
// Tác vụ ví dụ: Mô phỏng công việc xử lý dữ liệu
void SimulateWork() {
MY_LOG(DEBUG) << "Một tác vụ xử lý dữ liệu đang thực thi...";
}
} // namespace MyThreadPool
Kiểm tra với tác vụ:
#include <unistd.h> // for sleep
#include <functional>
#include "MyThreadPool.hpp" // Bao gồm cả định nghĩa ThreadPool và SimulateWork
#include "Task.hpp" // Để có TaskFunction và SimulateWork
using namespace MyThreadPool;
void Enable_Console_Log_Strategy() {
// Implement or mock
}
int main() {
Enable_Console_Log_Strategy();
ThreadPool<TaskFunction>* myThreadPool = new ThreadPool<TaskFunction>();
myThreadPool->Start();
int count = 10;
while (count--) {
myThreadPool->AddTask(SimulateWork);
sleep(1);
}
myThreadPool->Stop();
myThreadPool->Join();
delete myThreadPool;
return 0;
}
Kết quả chạy thử:
[2025-09-12 17:31:52] [INFO] Thread worker-123456789 started.
[2025-09-12 17:31:52] [INFO] Luồng mới khởi động: worker-123456789
... (các luồng khác) ...
[2025-09-12 17:31:52] [DEBUG] Một tác vụ xử lý dữ liệu đang thực thi...
[2025-09-12 17:31:53] [DEBUG] Một tác vụ xử lý dữ liệu đang thực thi...
... (các tác vụ khác được thực thi) ...
[2025-09-12 17:32:02] [INFO] Đánh thức tất cả luồng.
[2025-09-12 17:32:02] [INFO] worker-123456790 thoát. Hồ luồng muốn dừng và hàng đợi tác vụ rỗng.
... (các luồng khác thoát) ...
[2025-09-12 17:32:02] [INFO] Thread worker-123456789 joined.
... (các luồng khác joined) ...
Từ kết quả chạy, có thể thấy mọi thứ hoạt động đúng như mong đợi. Các tác vụ được xử lý tuần tự mà không bị lẫn lộn thông tin ghi nhật ký.
3. Mẫu thiết kế Đơn thể (Singleton) an toàn luồng
3.1. Khái niệm mẫu thiết kế Đơn thể
Mẫu thiết kế Đơn thể là một mẫu thiết kế khởi tạo (creational design pattern) đảm bảo rằng một lớp chỉ có một thể hiện duy nhất và cung cấp một điểm truy cập toàn cục để lấy thể hiện đó. Mẫu này giải quyết các kịch bản cần một đối tượng duy nhất toàn cục, tránh việc tạo ra nhiều thể hiện gây lãng phí tài nguyên hoặc không nhất quán trạng thái.
Các điểm thiết kế chính:
- Hàm tạo riêng tư (private constructor): Ngăn chặn việc tạo thể hiện bên ngoài bằng
new. - Biến thể hiện tĩnh (static instance variable): Lưu trữ thể hiện duy nhất của lớp.
- Phương thức truy cập tĩnh (static access method) (ví dụ:
getInstance()): Cung cấp điểm truy cập toàn cục, kiểm soát logic tạo thể hiện.
Kịch bản ứng dụng: Tải cấu hình, quản lý hồ luồng, hồ kết nối cơ sở dữ liệu, triển khai phiên (Session) và các kịch bản cần tài nguyên duy nhất toàn cục khác.
3.2. Đặc điểm của mẫu thiết kế Đơn thể
(1) Tính duy nhất
- Mọi lúc chỉ tồn tại một thể hiện duy nhất của lớp, được duy trì thông qua biến tĩnh.
- Ví dụ: Một lớp máy chủ quản lý hàng trăm GB dữ liệu bộ nhớ cần đơn thể để tránh tải trùng lặp.
(2) Điểm truy cập toàn cục
- Thông qua một phương thức tĩnh (ví dụ:
getInstance()) cung cấp một điểm truy cập thống nhất, đảm bảo tất cả mã sử dụng cùng một thể hiện.
(3) Tối ưu hóa tài nguyên
- Giảm chi phí: Tránh việc tạo/hủy đối tượng thường xuyên (như kết nối cơ sở dữ liệu).
- Tính nhất quán dữ liệu: Thể hiện duy nhất đảm bảo trạng thái tài nguyên chia sẻ thống nhất (như thông tin cấu hình).
(4) Thách thức về an toàn luồng
- Trong môi trường đa luồng, cần có cơ chế bổ sung (như khóa, kiểm tra kép) để ngăn chặn việc tạo nhiều thể hiện.
Ví dụ thực tế: Giống như một người đàn ông chỉ có thể có một người vợ (trong xã hội một vợ một chồng), một số thành phần hệ thống cũng chỉ cần một thể hiện. Trong nhiều kịch bản phát triển máy chủ, thường cần máy chủ tải lượng lớn dữ liệu (hàng trăm GB) vào bộ nhớ. Ví dụ: thông tin sản phẩm trên nền tảng thương mại điện tử, biểu đồ quan hệ người dùng trên mạng xã hội. Lúc này, thường sử dụng một lớp đơn thể để quản lý dữ liệu này, tránh lãng phí bộ nhớ do tải trùng lặp.
3.3. Phương pháp triển khai: Khởi tạo Sớm (Eager) và Lười (Lazy)
Khởi tạo Sớm (Eager Initialization)
- Đặc điểm: Thể hiện được tạo ngay khi lớp được tải.
- Ưu điểm: An toàn luồng, không cần lo lắng về vấn đề đa luồng.
- Nhược điểm: Nếu thể hiện không bao giờ được sử dụng, sẽ gây lãng phí tài nguyên.
- Kịch bản ứng dụng: Thời gian khởi tạo thể hiện ngắn và thể hiện chắc chắn sẽ được sử dụng trong quá trình chạy chương trình.
Khởi tạo Lười (Lazy Initialization)
- Đặc điểm: Thể hiện chỉ được tạo khi được sử dụng lần đầu tiên.
- Ưu điểm: Tận dụng tài nguyên hiệu quả, tránh khởi tạo không cần thiết.
- Nhược điểm: Cần xử lý vấn đề an toàn luồng.
- Kịch bản ứng dụng: Thời gian khởi tạo thể hiện dài hoặc chiếm nhiều tài nguyên, và có thể không được sử dụng ngay lập tức.
So sánh:
- Kiểu khởi tạo Sớm → Rửa bát ngay sau bữa ăn: Sẵn sàng dùng cho lần sau, nhưng có thể rửa những chiếc bát không dùng.
- Kiểu khởi tạo Lười → Rửa bát trước bữa ăn tiếp theo: Tiết kiệm tài nguyên, nhưng cần xử lý tạm thời.
3.4. Triển khai mẫu Đơn thể theo kiểu Khởi tạo Sớm
template <typename T>
class EagerSingleton {
private:
// Thể hiện duy nhất của lớp, được khởi tạo ngay khi chương trình bắt đầu
static T s_instance;
// Hàm tạo riêng tư để ngăn chặn việc tạo đối tượng bên ngoài
EagerSingleton() = default;
~EagerSingleton() = default;
// Xóa các hàm tạo sao chép và toán tử gán để ngăn chặn sao chép đối tượng
EagerSingleton(const EagerSingleton&) = delete;
EagerSingleton& operator=(const EagerSingleton&) = delete;
public:
// Phương thức tĩnh để lấy thể hiện duy nhất
static T* GetInstance() {
return &s_instance;
}
};
// Khởi tạo biến thành viên tĩnh bên ngoài lớp
template <typename T>
T EagerSingleton<T>::s_instance;
Phân tích các điểm chính:
- Khởi tạo thành viên tĩnh:
static T s_instancelà biến thành viên tĩnh, được khởi tạo khi chương trình khởi động (trước khi hàm main thực thi).- Điều này đảm bảo thể hiện được tạo sớm, tránh các điều kiện tranh chấp trong môi trường đa luồng.
- An toàn luồng:
- Vì thể hiện được tạo khi chương trình khởi động, không tồn tại vấn đề đa luồng cùng lúc tạo thể hiện.
- An toàn luồng một cách tự nhiên, không cần cơ chế đồng bộ hóa bổ sung.
- Kiểm soát truy cập:
- Hàm tạo và hàm hủy riêng tư, ngăn chặn việc tạo hoặc hủy thể hiện từ bên ngoài.
- Xóa hàm tạo sao chép và toán tử gán, ngăn chặn việc tạo thể hiện mới thông qua sao chép.
- Lấy thể hiện:
- Phương thức
GetInstance()trực tiếp trả về địa chỉ của thể hiện tĩnh, đơn giản và hiệu quả.
- Phương thức
- Ưu và nhược điểm:
- Ưu điểm: Triển khai đơn giản, an toàn luồng, hiệu suất cao (không khóa).
- Nhược điểm: Nếu thể hiện lớn hoặc thời gian khởi tạo tốn kém, sẽ kéo dài thời gian khởi động chương trình; chiếm dụng tài nguyên ngay cả khi không sử dụng.
3.5. Triển khai mẫu Đơn thể theo kiểu Khởi tạo Lười (Phiên bản cơ bản)
template <typename T>
class LazySingletonBasic {
private:
// Con trỏ tĩnh, ban đầu là nullptr
static T* s_instance;
// Hàm tạo và hủy riêng tư
LazySingletonBasic() = default;
~LazySingletonBasic() = default;
// Xóa các hàm tạo sao chép và toán tử gán
LazySingletonBasic(const LazySingletonBasic&) = delete;
LazySingletonBasic& operator=(const LazySingletonBasic&) = delete;
public:
// Phương thức tĩnh để lấy thể hiện
static T* GetInstance() {
if (s_instance == nullptr) {
s_instance = new T(); // Tạo thể hiện khi gọi lần đầu tiên
}
return s_instance;
}
};
// Khởi tạo thành viên tĩnh bên ngoài lớp
template <typename T>
T* LazySingletonBasic<T>::s_instance = nullptr;
Phân tích các điểm chính:
- Khởi tạo trễ:
- Sử dụng con trỏ tĩnh
s_instancekhởi tạo lànullptr. - Thể hiện chỉ được tạo khi phương thức
GetInstance()được gọi lần đầu tiên.
- Sử dụng con trỏ tĩnh
- Vấn đề an toàn luồng:
- Đây là nhược điểm chính của phiên bản cơ bản.
- Nếu nhiều luồng cùng lúc kiểm tra
s_instance == nullptr, tất cả có thể vượt qua kiểm tra, dẫn đến việc tạo ra nhiều thể hiện. - Vi phạm nguyên tắc cơ bản của mẫu Đơn thể.
- Quản lý bộ nhớ:
- Sử dụng
newđể tạo thể hiện nhưng không có thao tácdeletetương ứng. - Có thể dẫn đến rò rỉ bộ nhớ (mặc dù hệ điều hành sẽ thu hồi bộ nhớ khi chương trình kết thúc).
- Sử dụng
- Ưu và nhược điểm:
- Ưu điểm: Khởi tạo trễ, tiết kiệm tài nguyên.
- Nhược điểm: Không an toàn luồng, có thể tạo ra nhiều thể hiện.
3.6. Triển khai mẫu Đơn thể theo kiểu Khởi tạo Lười (Phiên bản an toàn luồng)
#include <mutex> // Cho std::mutex và std::lock_guard
template <typename T>
class LazySingletonThreadSafe {
private:
// Con trỏ tĩnh, volatile để ngăn chặn trình biên dịch tối ưu hóa quá mức
// (trong C++11 trở lên, std::atomic<T*> là cách tốt hơn).
volatile static T* s_instance;
// Khóa tương hỗ để đảm bảo an toàn luồng
static std::mutex s_mutex;
// Hàm tạo và hủy riêng tư
LazySingletonThreadSafe() = default;
~LazySingletonThreadSafe() = default;
// Xóa các hàm tạo sao chép và toán tử gán
LazySingletonThreadSafe(const LazySingletonThreadSafe&) = delete;
LazySingletonThreadSafe& operator=(const LazySingletonThreadSafe&) = delete;
public:
static T* GetInstance() {
// Kiểm tra lần thứ nhất: Tránh cạnh tranh khóa không cần thiết
if (s_instance == nullptr) {
std::lock_guard<std::mutex> lock_guard(s_mutex); // Khóa theo kiểu RAII
// Kiểm tra lần thứ hai: Đảm bảo chỉ một luồng tạo thể hiện
if (s_instance == nullptr) {
s_instance = new T();
}
}
return const_cast<T*>(s_instance); // Trả về con trỏ không volatile
}
};
// Khởi tạo thành viên tĩnh bên ngoài lớp
template <typename T>
volatile T* LazySingletonThreadSafe<T>::s_instance = nullptr;
template <typename T>
std::mutex LazySingletonThreadSafe<T>::s_mutex;
Phân tích các điểm chính:
- Mẫu khóa kiểm tra kép (Double-Checked Locking Pattern - DCLP):
- Kiểm tra lần thứ nhất
if (s_instance == nullptr)để tránh cạnh tranh khóa không cần thiết. - Chỉ khi thể hiện chưa được tạo mới vào khối đồng bộ hóa.
- Kiểm tra lần thứ hai đảm bảo chỉ một luồng tạo thể hiện.
- Kiểm tra lần thứ nhất
- An toàn luồng:
- Sử dụng
std::mutexvàstd::lock_guardđể đảm bảo an toàn luồng. lock_guardsử dụng kỹ thuật RAII, tự động quản lý vòng đời của khóa.
- Sử dụng
- Từ khóa
volatile:- Ngăn chặn trình biên dịch tối ưu hóa bằng cách sắp xếp lại các lệnh.
- Đảm bảo trong môi trường đa luồng, giá trị mới nhất được đọc, chứ không phải giá trị được cache trong thanh ghi.
- Vấn đề hàng rào bộ nhớ (Memory Barrier):
- Trước C++11, DCLP có thể gặp vấn đề sắp xếp lại lệnh.
s_instance = new T()có thể được sắp xếp lại thành: cấp phát bộ nhớ → gán địa chỉ chos_instance→ gọi hàm tạo.- Điều này có thể dẫn đến các luồng khác thấy
s_instancekhông rỗng nhưng thể hiện chưa được khởi tạo hoàn chỉnh. - Mô hình bộ nhớ của C++11 đã giải quyết vấn đề này, nhưng sử dụng
volatilelà một đảm bảo bổ sung.
- Hàm tạo và hàm hủy riêng tư:
- Ngăn chặn việc tạo thể hiện từ bên ngoài.
- Ngăn chặn việc tạo thể hiện mới thông qua hàm tạo sao chép hoặc toán tử gán.
- Ưu và nhược điểm:
- Ưu điểm: An toàn luồng, khởi tạo trễ, hiệu suất tốt (phần lớn thời gian không cần khóa).
- Nhược điểm: Triển khai tương đối phức tạp, cần chú ý đến vấn đề sắp xếp lại lệnh (mặc dù đã được giải quyết trong C++11).
Cách triển khai này vừa đảm bảo an toàn luồng, vừa tránh cạnh tranh khóa không cần thiết, là một triển khai kinh điển của mẫu Đơn thể khởi tạo lười.
4. Hồ Luồng Đơn Thể
Bản thân hồ luồng là một tài nguyên hệ thống quan trọng. Việc tạo nhiều thể hiện hồ luồng sẽ dẫn đến:
- Số lượng luồng quá nhiều, làm tăng chi phí chuyển đổi ngữ cảnh.
- Lãng phí tài nguyên bộ nhớ (mỗi luồng cần cấp phát không gian stack).
- Khó khăn trong việc giám sát và thống kê tình hình sử dụng luồng chung.
- Nhiều hồ luồng có thể tranh giành cùng một tài nguyên hệ thống.
- Phân bổ tác vụ không cân bằng, có thể khiến một số hồ luồng quá tải trong khi các hồ khác rảnh rỗi.
- Tăng cường sự phức tạp trong việc điều phối và đồng bộ hóa giữa các luồng.
Triển khai hồ luồng theo kiểu đơn thể nhằm mục đích quản lý thống nhất tài nguyên luồng, nâng cao hiệu suất hệ thống, đảm bảo ổn định, và cung cấp một giao diện lập trình song song toàn cục, đơn giản.
Dưới đây, chúng ta sẽ triển khai hồ luồng đơn thể theo kiểu khởi tạo lười an toàn luồng.
Đầu tiên, cần đặt hàm tạo và hàm hủy ở chế độ riêng tư, hàm Start cũng cần riêng tư, đồng thời xóa rõ ràng hàm tạo sao chép và toán tử gán.
// Đặt các hàm này vào phần private của lớp ThreadPool
// template <class T>
// class ThreadPool {
// private:
// ThreadPool(int num = kDefaultThreadCount)
// : _activeThreadCount(num), _isRunning(false), _waitingThreadCount(0)
// {
// for (int i = 0; i < _activeThreadCount; ++i) {
// _workerThreads.emplace_back(
// [this]() {
// WorkerRoutine();
// });
// }
// }
//
// ~ThreadPool() {
// // Đảm bảo hồ luồng đã dừng và các luồng đã được join trước khi hủy
// if (_isRunning) {
// Stop();
// Join();
// }
// }
//
// // Xóa các hàm tạo sao chép và toán tử gán
// ThreadPool(const ThreadPool&) = delete;
// ThreadPool& operator=(const ThreadPool&) = delete;
//
// // Hàm Start() cũng cần private
// void Start() {
// if (_isRunning) return;
// _isRunning = true;
// for (auto& thread : _workerThreads) {
// thread.Start();
// MY_LOG(INFO) << "Luồng mới khởi động: " << thread.Name();
// }
// }
// // ... các thành viên và phương thức khác
// };
Định nghĩa con trỏ tĩnh và khóa tĩnh để triển khai mẫu Đơn thể:
// Trong lớp ThreadPool
// template <class T>
// class ThreadPool {
// // ... các thành viên hiện có ...
//
// private:
// // ...
// static ThreadPool<T>* s_instance; // Con trỏ thể hiện đơn thể
// static Mutex s_singletonMutex; // Khóa cho việc khởi tạo đơn thể
// };
// Khởi tạo thành viên tĩnh bên ngoài lớp
// template<class T>
// ThreadPool<T>* ThreadPool<T>::s_instance = nullptr;
// template<class T>
// Mutex ThreadPool<T>::s_singletonMutex;
Triển khai hàm GetInstance:
namespace MyThreadPool {
// ... (ThreadPool class definition as above, but with private constructor, etc.)
template <class T>
class ThreadPool {
private:
// ... (private members: _workerThreads, _activeThreadCount, _pendingTasks, _taskAvailableCond, _queueMutex, _isRunning, _waitingThreadCount)
// ... (private methods: ThreadPool constructor, ~ThreadPool, operator=, WorkerRoutine, Start, NotifyAllWorkers)
static ThreadPool<T>* s_instance; // Con trỏ thể hiện đơn thể
static Mutex s_singletonMutex; // Khóa cho việc khởi tạo đơn thể
public:
// Phương thức công khai để lấy thể hiện duy nhất của hồ luồng
static ThreadPool* GetInstance() {
// Kiểm tra lần thứ nhất (không khóa)
if (s_instance == nullptr) {
LockGuard lock(s_singletonMutex); // Khóa
MY_LOG(DEBUG) << "Đang lấy thể hiện đơn thể...";
// Kiểm tra lần thứ hai (sau khi khóa)
if (s_instance == nullptr) {
MY_LOG(DEBUG) << "Sử dụng lần đầu, tạo thể hiện đơn thể...";
s_instance = new ThreadPool<T>();
s_instance->Start(); // Khởi động hồ luồng ngay khi được tạo
}
}
return s_instance;
}
// Các phương thức công khai còn lại
void Stop() { /* ... */ }
void Join() { /* ... */ }
bool AddTask(const T& task) { /* ... */ }
};
// Khởi tạo thành viên tĩnh bên ngoài lớp
template <class T>
ThreadPool<T>* ThreadPool<T>::s_instance = nullptr;
template <class T>
Mutex ThreadPool<T>::s_singletonMutex;
} // namespace MyThreadPool
Kiểm tra thử nghiệm:
#include <unistd.h> // for sleep
#include <functional>
#include "MyThreadPool.hpp" // Bao gồm cả định nghĩa ThreadPool, TaskFunction và SimulateWork
using namespace MyThreadPool;
void Enable_Console_Log_Strategy() {
// Implement or mock
}
int main() {
Enable_Console_Log_Strategy();
int count = 10;
while (count--) {
// Lấy thể hiện đơn thể và thêm tác vụ
ThreadPool<TaskFunction>::GetInstance()->AddTask(SimulateWork);
sleep(1);
}
// Dừng và join hồ luồng
ThreadPool<TaskFunction>::GetInstance()->Stop();
ThreadPool<TaskFunction>::GetInstance()->Join();
// Lưu ý: Đối tượng Singleton thường không được xóa rõ ràng
// vì nó có vòng đời kéo dài đến khi chương trình kết thúc.
// Tuy nhiên, nếu cần giải phóng tài nguyên một cách có kiểm soát
// thì cần một phương thức Shutdown/Destroy riêng cho Singleton.
return 0;
}
Kết quả chạy thử:
[2025-09-12 22:33:16] [DEBUG] Đang lấy thể hiện đơn thể...
[2025-09-12 22:33:16] [DEBUG] Sử dụng lần đầu, tạo thể hiện đơn thể...
[2025-09-12 22:33:16] [INFO] Thread worker-123456789 started.
[2025-09-12 22:33:16] [INFO] Luồng mới khởi động: worker-123456789
... (tương tự cho các luồng khác) ...
[2025-09-12 22:33:16] [DEBUG] Một tác vụ xử lý dữ liệu đang thực thi...
[2025-09-12 22:33:17] [DEBUG] Một tác vụ xử lý dữ liệu đang thực thi...
... (các tác vụ khác được thực thi) ...
[2025-09-12 22:33:26] [INFO] Đánh thức tất cả luồng.
[2025-09-12 22:33:26] [INFO] worker-123456790 thoát. Hồ luồng muốn dừng và hàng đợi tác vụ rỗng.
... (các luồng khác thoát) ...
[2025-09-12 22:33:26] [INFO] Thread worker-123456789 joined.
... (tương tự cho các luồng khác joined) ...
Từ kết quả chạy, có thể thấy không có vấn đề gì. Hơn nữa, sau khi sử dụng nhật ký để xuất ra console, thông tin in ra cũng không bị lẫn lộn.
5. An toàn Luồng và Khả năng Tái Nhập
5.1. Khái niệm
An toàn luồng (Thread Safety): Là khả năng của một chương trình hoặc đoạn mã có thể thực thi đúng đắn khi nhiều luồng truy cập cùng lúc vào một tài nguyên chia sẻ mà không làm hỏng dữ liệu hoặc gây nhiễu lẫn nhau. Thông thường, khi nhiều luồng thực thi đồng thời cùng một đoạn mã chỉ chứa biến cục bộ, sẽ không tạo ra kết quả khác nhau. Tuy nhiên, nếu đoạn mã thao tác trên biến toàn cục hoặc biến tĩnh mà không có bảo vệ bằng khóa, vấn đề an toàn luồng rất dễ xảy ra.
Khả năng tái nhập (Reentrancy): Là khả năng của một hàm có thể được gọi bởi nhiều luồng thực thi khác nhau, hoặc bởi cùng một luồng nhưng trước khi lần gọi trước đó kết thúc. Nếu một hàm vẫn có thể duy trì kết quả nhất quán và không gặp vấn đề gì khi được tái nhập, nó được gọi là hàm khả tái nhập (reentrant function). Ngược lại, đó là hàm bất khả tái nhập (non-reentrant function).
Hiện tại, chúng ta có thể hiểu rằng khả năng tái nhập chủ yếu xảy ra trong hai trường hợp:
- Hàm được tái nhập bởi nhiều luồng.
- Tín hiệu (signal) khiến luồng thực thi lặp lại việc vào hàm.
Các trường hợp không an toàn luồng thường gặp:
- Hàm không bảo vệ biến chia sẻ.
- Hàm có trạng thái thay đổi theo số lần gọi.
- Hàm trả về con trỏ đến biến tĩnh.
- Hàm gọi các hàm khác không an toàn luồng.
Các trường hợp bất khả tái nhập thường gặp:
- Hàm gọi
malloc/free, vì chúng sử dụng danh sách toàn cục để quản lý bộ nhớ heap. - Hàm gọi các hàm thư viện I/O chuẩn, vì chúng thường dựa vào cấu trúc dữ liệu toàn cục bất khả tái nhập.
- Hàm sử dụng cấu trúc dữ liệu tĩnh bên trong.
Các trường hợp an toàn luồng thường gặp:
- Chỉ đọc biến toàn cục/tĩnh mà không có thao tác ghi.
- Lớp hoặc giao diện cung cấp các thao tác nguyên tử (atomic operations).
- Chuyển đổi giữa các luồng không gây ra sự mơ hồ về kết quả thực thi giao diện.
Các trường hợp khả tái nhập thường gặp:
- Không sử dụng biến toàn cục hoặc tĩnh.
- Không sử dụng cấp phát bộ nhớ động (
malloc/new). - Không gọi các hàm bất khả tái nhập.
- Không trả về dữ liệu tĩnh/toàn cục, tất cả dữ liệu được cung cấp bởi bên gọi.
- Sử dụng dữ liệu cục bộ hoặc bản sao cục bộ của dữ liệu toàn cục để bảo vệ trạng thái toàn cục.
5.2. Phân tích mối quan hệ giữa Khả năng Tái nhập và An toàn Luồng
Đừng để bị phức tạp bởi các thuật ngữ chuyên môn; qua phân tích kỹ lưỡng, bạn sẽ thấy bản chất của các khái niệm này có mối liên hệ với nhau. Hãy cùng khám phá sâu hơn mối quan hệ giữa hàm khả tái nhập và an toàn luồng.
Mối liên hệ giữa Khả năng Tái nhập và An toàn Luồng
Mối quan hệ tương ứng cơ bản
- Hàm khả tái nhập nhất định là an toàn luồng: Nếu một hàm được thiết kế để khả tái nhập, thì đương nhiên nó là an toàn luồng. Đây là điểm cốt lõi nhất.
- Ví dụ: Một hàm tính toán thuần túy chỉ sử dụng biến cục bộ có thể được nhiều luồng gọi một cách an toàn và cũng có thể được sử dụng an toàn trong một bộ xử lý tín hiệu.
- Hàm bất khả tái nhập tiềm ẩn rủi ro: Hàm bất khả tái nhập không thể được nhiều luồng sử dụng đồng thời, nếu không có thể gây ra tranh chấp dữ liệu, lỗi bộ nhớ và các vấn đề an toàn luồng khác.
- Ví dụ điển hình: Hàm
strtok()sử dụng bộ đệm tĩnh, trong môi trường đa luồng sẽ dẫn đến kết quả không mong đợi. - Ảnh hưởng của biến toàn cục: Hàm sử dụng biến toàn cục sẽ đồng thời mất đi khả năng tái nhập và an toàn luồng, vì trạng thái toàn cục sẽ được chia sẻ bởi tất cả các bên gọi.
- Ví dụ: Một hàm sử dụng
static int counterđể đếm số lần gọi, trong môi trường đa luồng, việc đếm sẽ sai.
Sự khác biệt giữa Khả năng Tái nhập và An toàn Luồng
Phạm vi khái niệm
- Mối quan hệ bao hàm: Hàm khả tái nhập là một tập con của hàm an toàn luồng. Tất cả các hàm khả tái nhập đều là an toàn luồng, nhưng không phải tất cả các hàm an toàn luồng đều là khả tái nhập.
- Ví dụ tương tự: Giống như tất cả hình vuông đều là hình chữ nhật, nhưng không phải tất cả hình chữ nhật đều là hình vuông.
- Ảnh hưởng của cơ chế khóa:
- Hàm an toàn luồng được triển khai thông qua việc khóa: Các hàm này an toàn trong môi trường đa luồng, nhưng nếu liên quan đến tái nhập (như được gọi trong một bộ xử lý tín hiệu), có thể dẫn đến tắc nghẽn (deadlock).
- Kịch bản ví dụ: Một luồng đã có khóa tương hỗ bị gián đoạn bởi một tín hiệu trong quá trình thực thi, và bộ xử lý tín hiệu lại cố gắng lấy cùng một khóa.
- Hàm khả tái nhập thực sự: Không phụ thuộc vào cơ chế khóa, thường được triển khai bằng cách tránh trạng thái chia sẻ hoặc sử dụng lưu trữ cục bộ cho luồng.
Lưu ý đặc biệt
Cân nhắc kịch bản ứng dụng
- Ảnh hưởng của xử lý tín hiệu: Trong hầu hết các trường hợp, nếu không xem xét trường hợp đặc biệt luồng thực thi tái nhập do tín hiệu, an toàn luồng và khả năng tái nhập có thể không cần phân biệt quá nghiêm ngặt về mặt an toàn.
- Sự khác biệt về trọng tâm:
- An toàn luồng: Tập trung mô tả các đặc tính an toàn khi nhiều luồng truy cập đồng thời vào tài nguyên chia sẻ, phản ánh hành vi của chương trình trong môi trường song song.
- Kịch bản ứng dụng: Thiết kế máy chủ đa luồng, đảm bảo an toàn luồng của các cấu trúc dữ liệu chia sẻ.
- Khả năng tái nhập: Mô tả đặc tính của hàm có thể được "tái nhập an toàn" hay không, thể hiện các đặc điểm thiết kế của chính hàm đó.
- Kịch bản ứng dụng: Khi viết hàm xử lý tín hiệu hoặc thuật toán đệ quy, phải sử dụng hàm khả tái nhập.
Gợi ý phát triển thực tế
- Trong bộ xử lý tín hiệu, chỉ sử dụng các hàm được ghi chú rõ ràng là "khả tái nhập".
- Khi lập trình đa luồng, ưu tiên chọn phiên bản hàm an toàn luồng.
- Đối với mã quan trọng về hiệu suất, triển khai khả tái nhập thường hiệu quả hơn triển khai an toàn luồng có khóa.
6. Các khái niệm khóa thường gặp
6.1. Tắc nghẽn (Deadlock)
Tắc nghẽn (Deadlock) là một trạng thái cạnh tranh tài nguyên trong hệ thống đa tiến trình/luồng. Khi một nhóm các tiến trình mà mỗi tiến trình đều giữ ít nhất một tài nguyên không thể bị tước đoạt (tức là tài nguyên không bị hệ thống cưỡng chế thu hồi trong quá trình sử dụng), đồng thời lại chờ đợi để lấy các tài nguyên đang bị các tiến trình khác trong nhóm chiếm giữ, sẽ hình thành một chuỗi chờ đợi vòng tròn, dẫn đến tất cả các tiến trình liên quan không thể tiếp tục thực thi, hệ thống rơi vào trạng thái chặn vĩnh viễn.
Để minh họa, giả sử luồng A và luồng B phải đồng thời lấy khóa 1 và khóa 2 mới có thể tiếp tục truy cập các tài nguyên tiếp theo.
- Luồng A lấy khóa 1, sau đó cố gắng lấy khóa 2.
- Luồng B lấy khóa 2, sau đó cố gắng lấy khóa 1.
Việc yêu cầu một khóa là thao tác nguyên tử, nhưng yêu cầu nhiều khóa thì chưa chắc đã đảm bảo tính nguyên tử. Kết quả có thể là:
Luồng A giữ khóa 1 và chờ khóa 2. Luồng B giữ khóa 2 và chờ khóa 1. Cả hai luồng đều bị chặn vĩnh viễn.
6.2. Bốn điều kiện cần thiết để xảy ra tắc nghẽn
Điều kiện tương hỗ loại trừ (Mutual Exclusion): Đây là một trong bốn điều kiện cần thiết để xảy ra tắc nghẽn. Nó chỉ ra rằng trong môi trường song song, một tài nguyên (như máy in, bộ nhớ chia sẻ, tệp tin, v.v.) chỉ có thể được một luồng thực thi (luồng hoặc tiến trình) độc quyền sử dụng tại một thời điểm. Khi một luồng thực thi đã chiếm được tài nguyên, các luồng thực thi khác phải chờ đợi cho đến khi tài nguyên đó được giải phóng. Điều kiện này đảm bảo tính độc quyền của tài nguyên, nhưng đồng thời cũng có thể dẫn đến tắc nghẽn.
Điều kiện Yêu cầu và Giữ (Request and Hold Condition): Cũng là một trong bốn điều kiện cần thiết để xảy ra tắc nghẽn. Nó chỉ ra rằng trong hệ thống song song, khi một luồng thực thi (như tiến trình hoặc luồng) bị chặn vì yêu cầu tài nguyên mới, nó vẫn giữ các tài nguyên đã chiếm được. Tình trạng này dẫn đến việc nhiều luồng thực thi chờ đợi lẫn nhau giải phóng tài nguyên, từ đó hình thành tắc nghẽn.
Cụ thể, điều kiện Yêu cầu và Giữ bao gồm hai khía cạnh chính:
- Yêu cầu tài nguyên mới: Luồng thực thi cố gắng yêu cầu tài nguyên mới trong khi đang giữ một số tài nguyên khác.
- Giữ tài nguyên đã có: Khi yêu cầu tài nguyên mới không thành công, luồng sẽ không giải phóng các tài nguyên đang giữ.
Điều kiện Không Tước đoạt (Non-preemptive Condition): Còn được gọi là điều kiện không thể bị chiếm quyền. Điều kiện này yêu cầu tài nguyên mà một tiến trình đã chiếm được trong quá trình thực thi không thể bị các tiến trình khác hoặc hệ thống cưỡng chế tước đoạt trước khi nó được sử dụng xong. Chỉ khi tiến trình chủ động giải phóng tài nguyên, các tiến trình khác mới có thể chiếm lấy chúng.
Giải thích các điểm chính:
- Trạng thái giữ tài nguyên:
- Tiến trình có thể chiếm giữ một số tài nguyên (như bộ nhớ, thiết bị I/O, khóa tệp, v.v.) trong quá trình thực thi.
- Một khi các tài nguyên này được cấp phát, trừ khi tiến trình chủ động giải phóng, hệ thống không thể cưỡng chế thu hồi.
- Ảnh hưởng của tước đoạt:
- Nếu hệ thống cho phép cưỡng chế tước đoạt tài nguyên (như luân chuyển thời gian CPU), có thể dẫn đến thực thi tiến trình bất thường hoặc không nhất quán dữ liệu.
- Ví dụ, khi một tiến trình đang ghi vào tệp, nếu quyền truy cập đĩa đột ngột bị tước đoạt, có thể dẫn đến hỏng tệp.
Điều kiện Chờ đợi Vòng tròn (Circular Wait Condition): Là một tình huống tắc nghẽn thường gặp trong lập trình đa luồng hoặc phân bổ tài nguyên hệ điều hành. Cụ thể, nó biểu hiện như sau: có nhiều luồng thực thi (luồng hoặc tiến trình) chạy đồng thời, mỗi luồng thực thi đang chờ đợi các luồng thực thi khác giải phóng tài nguyên, và các mối quan hệ chờ đợi này tạo thành một chuỗi vòng tròn khép kín.
6.3. Tránh tắc nghẽn
Tắc nghẽn thường xảy ra khi nhiều tiến trình hoặc luồng chờ đợi lẫn nhau giải phóng tài nguyên. Phòng ngừa tắc nghẽn cần bắt đầu từ chiến lược phân bổ và yêu cầu tài nguyên.
Phá vỡ điều kiện tương hỗ loại trừ Một số tài nguyên có thể được sử dụng theo cách chia sẻ, tránh độc quyền. Ví dụ, tệp chỉ đọc có thể cho phép nhiều tiến trình truy cập đồng thời, giảm cạnh tranh.
Phá vỡ điều kiện chiếm giữ và chờ đợi Tiến trình phải yêu cầu tất cả các tài nguyên cần thiết cùng một lúc trước khi bắt đầu thực thi. Nếu không thể đáp ứng, tiến trình sẽ không được cấp phát bất kỳ tài nguyên nào. Cách này có thể làm giảm hiệu suất sử dụng tài nguyên.
Phá vỡ điều kiện không tước đoạt Nếu tiến trình không thể nhận thêm tài nguyên, nó phải giải phóng các tài nguyên đã chiếm giữ. Chiến lược này phù hợp với các tài nguyên mà trạng thái dễ dàng lưu và khôi phục, như các thanh ghi CPU.
Phá vỡ điều kiện chờ đợi vòng tròn Sắp xếp thứ tự tuyến tính cho các loại tài nguyên, yêu cầu tiến trình phải yêu cầu tài nguyên theo thứ tự số hiệu. Ví dụ, tiến trình chỉ có thể yêu cầu tài nguyên có số hiệu nhỏ hơn trước, sau đó mới yêu cầu tài nguyên có số hiệu lớn hơn.
Các thuật toán tránh tắc nghẽn
Thuật toán Ngân hàng (Banker's Algorithm) Kiểm tra xem hệ thống có ở trạng thái an toàn hay không bằng cách mô phỏng việc phân bổ tài nguyên. Trước mỗi lần phân bổ tài nguyên, thuật toán sẽ xác định xem tài nguyên còn lại có thể đáp ứng nhu cầu tối đa của ít nhất một tiến trình hay không, từ đó tránh rơi vào trạng thái không an toàn.
Thuật toán Đồ thị phân bổ tài nguyên Phát hiện sự tồn tại của chu trình bằng cách duy trì đồ thị phân bổ tài nguyên. Nếu không có chu trình trong đồ thị, hệ thống sẽ không bị tắc nghẽn; nếu có chu trình, tắc nghẽn có thể xảy ra.
Chiến lược phát hiện và phục hồi
Phát hiện tắc nghẽn định kỳ Thường xuyên quét trạng thái hệ thống bằng thuật toán đồ thị phân bổ tài nguyên hoặc đồ thị chờ đợi. Ngay khi phát hiện tắc nghẽn, áp dụng các biện pháp phục hồi.
Chấm dứt tiến trình Buộc chấm dứt một hoặc nhiều tiến trình bị tắc nghẽn, giải phóng các tài nguyên mà chúng đang chiếm giữ. Có thể chọn chấm dứt tiến trình có chi phí thấp nhất, ví dụ tiến trình chạy ngắn nhất hoặc chiếm ít tài nguyên nhất.
Tước đoạt tài nguyên Tước đoạt tài nguyên từ một số tiến trình để cấp phát cho các tiến trình khác. Tiến trình bị tước đoạt tài nguyên có thể cần quay lại điểm kiểm tra trước đó để thực thi lại.
Lời khuyên ứng dụng thực tế
- Khi viết chương trình đa luồng, cố gắng tuân thủ thứ tự cố định khi lấy khóa.
- Sử dụng cơ chế timeout để tránh luồng chờ đợi tài nguyên vô thời hạn.
- Giảm granularité của khóa, sử dụng khóa chi tiết thay cho khóa thô.
- Ưu tiên sử dụng các công cụ đồng thời cấp cao (như semaphore, biến điều kiện) thay vì thao tác trực tiếp với khóa.
Thông qua việc thiết kế chiến lược quản lý tài nguyên hợp lý, có thể giảm đáng kể xác suất xảy ra tắc nghẽn.
7. STL, Con trỏ thông minh và An toàn Luồng
Các container STL có an toàn luồng không?
Câu trả lời là không.
Điều này là do khi thiết kế STL, ưu tiên hàng đầu là tối ưu hóa hiệu suất, trong khi cơ chế khóa cần thiết cho an toàn luồng sẽ ảnh hưởng đáng kể đến hiệu suất. Hơn nữa, các loại container khác nhau (như khóa bảng và khóa thùng trong bảng băm, khóa toàn bộ bảng (granularité thô) hoặc khóa từng thùng (granularité mịn)) yêu cầu các chiến lược khóa khác nhau, và ảnh hưởng đến hiệu suất cũng khác nhau.
Vì vậy, STL mặc định không cung cấp đảm bảo an toàn luồng. Nếu muốn sử dụng trong môi trường đa luồng, nhà phát triển cần tự triển khai cơ chế an toàn luồng.
Con trỏ thông minh có an toàn luồng không?
unique_ptr là an toàn luồng, vì:
- Tính độc quyền sở hữu:
unique_ptráp dụng mô hình sở hữu độc quyền, bất cứ lúc nào một tài nguyên chỉ có thể được mộtunique_ptrsở hữu. - Phạm vi cục bộ: Vòng đời của
unique_ptrthường giới hạn trong phạm vi khối mã hiện tại, không chia sẻ giữa các luồng. - An toàn khi chuyển nhượng quyền sở hữu: Khi chuyển nhượng quyền sở hữu thông qua
std::move, thao tác là nguyên tử, không phát sinh điều kiện tranh chấp.
An toàn luồng của shared_ptr phức tạp hơn, chủ yếu thể hiện ở các khía cạnh sau:
- Tính nguyên tử của bộ đếm tham chiếu:
- Thư viện chuẩn sử dụng các thao tác nguyên tử (CAS, Compare-And-Swap) để đảm bảo các thao tác trên bộ đếm tham chiếu là an toàn luồng.
- Các thao tác tăng giảm bộ đếm tham chiếu là nguyên tử, không xảy ra điều kiện tranh chấp.
- An toàn luồng của khối điều khiển:
- Việc triển khai
shared_ptrbao gồm một khối điều khiển, trong đó lưu trữ bộ đếm tham chiếu và bộ đếm tham chiếu yếu. - Các thay đổi của khối điều khiển đều được bảo vệ bằng các thao tác nguyên tử.
- Việc triển khai
- Tính phi nguyên tử của truy cập dữ liệu:
- Mặc dù các thao tác trên bộ đếm tham chiếu là an toàn luồng, nhưng việc truy cập vào đối tượng được quản lý vẫn cần đồng bộ hóa bổ sung.
- Khi nhiều luồng cùng lúc truy cập vào cùng một đối tượng được quản lý bởi
shared_ptr, cần có một khóa tương hỗ riêng biệt.
Gợi ý sử dụng
unique_ptr: Ưu tiên sử dụng khi không cần chia sẻ quyền sở hữu giữa các luồng, hoàn toàn an toàn luồng.shared_ptr:- Các thao tác trên bộ đếm tham chiếu bản thân nó là an toàn luồng.
- Nhưng khi cần chia sẻ dữ liệu, vẫn cần cơ chế đồng bộ hóa bổ sung để bảo vệ việc truy cập dữ liệu.
- Khi truyền
shared_ptrgiữa các luồng, tốt nhất nên truyền bằng cách sao chép chứ không phải tham chiếu.
- Cân nhắc hiệu suất:
- Các thao tác nguyên tử của
shared_ptrsẽ có một chút chi phí hiệu suất. - Trong các kịch bản không cần an toàn luồng, có thể cân nhắc sử dụng phiên bản không an toàn luồng của
boost::shared_ptr.
- Các thao tác nguyên tử của
8. Các loại khóa phổ biến khác (Tham khảo)
1. Khóa bi quan (Pessimistic Lock)
- Ý tưởng cốt lõi: "Luôn có kẻ muốn hãm hại ta".
- Cách hoạt động: Tôi tin rằng chỉ cần tôi thao tác dữ liệu (dù là đọc hay ghi), chắc chắn sẽ có các luồng khác tranh giành, sửa đổi dữ liệu với tôi. Vì vậy, trước khi thao tác dữ liệu, tôi chắc chắn sẽ thêm khóa, "khóa" dữ liệu lại, để các luồng khác bị chặn bên ngoài, không thể thao tác. Sau khi tôi thao tác xong và giải phóng khóa, các luồng khác mới có thể vào.
- Ví dụ: Giống như bạn đi vào một nhà vệ sinh công cộng chỉ có một chỗ, bạn bi quan rằng chắc chắn sẽ có người đến tranh giành. Vì vậy, bạn vừa vào đã khóa cửa từ bên trong (thêm khóa), những người khác chỉ có thể chờ bên ngoài (bị chặn). Đến khi bạn đi xong và mở cửa ra (giải phóng khóa), người tiếp theo mới có thể vào.
- Triển khai phổ biến: Từ khóa
synchronized,ReentrantLock, v.v. - Ưu điểm: Đơn giản, đảm bảo an toàn luồng tuyệt đối.
- Nhược điểm: Bản thân việc thêm và giải phóng khóa có chi phí hiệu suất, và nếu cạnh tranh khóa dữ dội, sẽ dẫn đến nhiều luồng bị treo và đánh thức, rất tốn thời gian.
2. Khóa lạc quan (Optimistic Lock)
- Ý tưởng cốt lõi: "Chắc không ai sửa đâu, cứ làm trước đã".
- Cách hoạt động: Tôi tin rằng khi tôi thao tác dữ liệu, khả năng cao là không có luồng khác sửa đổi nó. Vì vậy, trước khi thao tác dữ liệu, tôi không thêm khóa, mà trực tiếp đọc. Nhưng khi cập nhật dữ liệu, tôi sẽ kiểm tra xem dữ liệu này đã bị người khác thay đổi hay chưa kể từ khi tôi đọc xong cho đến trước khi tôi cập nhật. Nếu chưa thay đổi, tôi sẽ cập nhật một cách an tâm; nếu đã thay đổi, việc cập nhật của tôi sẽ thất bại, và tôi sẽ chọn thử lại hoặc báo lỗi.
- Ví dụ: Giống như bạn sử dụng ứng dụng ghi chú đám mây (như Git, Google Docs). Bạn mở tài liệu và chỉnh sửa trực tiếp (không khóa). Khi bạn viết xong và nhấp "Lưu", hệ thống sẽ kiểm tra xem có ai khác đã lưu từ khi bạn mở tài liệu cho đến bây giờ hay chưa (kiểm tra phiên bản). Nếu không ai lưu, nội dung của bạn sẽ được lưu thành công. Nếu người khác đã lưu, hệ thống sẽ thông báo "phiên bản của bạn đã lỗi thời", yêu cầu bạn chỉnh sửa lại dựa trên phiên bản mới nhất (thử lại).
- Triển khai phổ biến: Cơ chế số phiên bản, thao tác CAS.
- Ưu điểm: Trong các kịch bản đọc nhiều ghi ít, hiệu suất cực cao vì nó tránh được chi phí khóa lớn.
- Nhược điểm: Nếu thao tác ghi rất thường xuyên, số lần thử lại cập nhật thất bại sẽ rất nhiều, ngược lại có thể làm giảm hiệu suất (thường gọi là "CPU quay không tải").
3. Thao tác CAS
- Là gì: Compare-And-Swap (So sánh và Hoán đổi), là một kỹ thuật triển khai cụ thể phổ biến nhất của khóa lạc quan.
- Quy trình hoạt động: Nó bao gồm ba toán hạng:
- V: Giá trị hiện tại trong bộ nhớ (giá trị hiện tại của biến tôi chuẩn bị cập nhật).
- A: Giá trị cũ mà tôi đã đọc được (tôi mong muốn giá trị trong bộ nhớ vẫn là này).
- B: Giá trị mới mà tôi muốn cập nhật thành.
- Ví dụ: Bạn thấy một món hàng, tồn kho hiển thị chỉ còn 1 sản phẩm (V = 1). Bạn nhanh chóng đặt hàng, khi thanh toán cuối cùng, hệ thống sẽ kiểm tra xem tồn kho hiện tại có còn là 1 không (Compare). Nếu có, sẽ trừ tồn kho, cho phép bạn mua thành công (Swap); nếu không (ví dụ đã bị người khác mua mất), sẽ báo bạn thất bại.
- Nhược điểm:
- Vấn đề ABA: Người khác có thể mua hết từ 1 sản phẩm, sau đó lại bổ sung thêm 1 sản phẩm, bạn thấy tồn kho vẫn là 1, nhưng nó không còn là cái 1 ban đầu nữa. Đối với các nghiệp vụ nhạy cảm, cần sử dụng số phiên bản để hỗ trợ giải quyết.
- Thời gian tự quay lâu tốn kém: Nếu không thành công liên tục, CAS sẽ thử lại không ngừng, tiêu tốn CPU.
- Chỉ có thể đảm bảo thao tác nguyên tử cho một biến chia sẻ duy nhất.
4. Khóa tự quay (Spinlock)
- Là gì: Nó là một loại khóa "chờ đợi ngốc nghếch", là một cách triển khai của khóa bi quan.
- Cách hoạt động: Khi một luồng cố gắng lấy khóa thất bại, nó không bị treo ngay lập tức (chuyển sang trạng thái chặn), mà sẽ thực hiện một vòng lặp bận rộn (tự quay), liên tục cố gắng lấy khóa cho đến khi thành công.
- Ví dụ: Bạn đang chờ nhà vệ sinh, có người bên trong. Cách làm của khóa bi quan là: bạn tìm một chiếc ghế sofa để ngủ (luồng bị treo), chờ người bên trong ra và gọi to bạn (đánh thức). Còn cách làm của khóa tự quay là: bạn không đi ngủ, mà đứng ở cửa liên tục gõ cửa hỏi "Xong chưa? Xong chưa? Xong chưa?" (liên tục thử).
- Kịch bản ứng dụng: Rất phù hợp với các trường hợp khóa được giữ trong thời gian rất ngắn. Bởi vì chi phí treo và đánh thức luồng lớn hơn nhiều so với chi phí tự quay một chút.
- Nhược điểm: Nếu khóa được giữ trong thời gian rất dài, luồng tự quay sẽ lãng phí thời gian CPU một cách vô ích.
5. Khóa đọc-ghi (Read-Write Lock)
- Là gì: Một loại khóa bi quan đặc biệt, nó chia các thao tác khóa thành khóa đọc và khóa ghi.
- Quy tắc hoạt động (quy tắc cốt lõi):
- Đọc chia sẻ: Nhiều luồng có thể đồng thời giữ khóa đọc, thực hiện thao tác đọc.
- Ghi độc quyền: Khóa ghi là độc quyền. Khi một luồng giữ khóa ghi, tất cả các luồng khác (dù muốn đọc hay muốn ghi) đều phải chờ.
- Đọc và ghi loại trừ lẫn nhau: Khi một luồng giữ khóa đọc, các luồng khác có thể đọc, nhưng không thể ghi. Khi một luồng giữ khóa ghi, các luồng khác không thể đọc cũng không thể ghi.
- Ví dụ: Một bảng thông báo.
- Đọc: Nhiều học sinh có thể đồng thời xem bảng thông báo (đọc chia sẻ).
- Ghi: Nếu một học sinh muốn lên sửa bảng thông báo (ghi), em đó phải chờ tất cả các học sinh đang xem rời đi (giải phóng khóa đọc), và em đó sẽ độc quyền bảng thông báo, không cho người khác xem hay ghi (ghi độc quyền).
- Kịch bản ứng dụng: Các kịch bản đọc nhiều ghi ít, có thể cải thiện hiệu suất đáng kể (vì các thao tác đọc có thể thực hiện song song).
- Triển khai phổ biến: Giao diện
ReadWriteLockvà triển khai của nóReentrantReadWriteLock.
Bảng so sánh tóm tắt
| Loại khóa | Ý tưởng cốt lõi | Ưu điểm | Nhược điểm | Kịch bản ứng dụng |
|---|---|---|---|---|
| Khóa bi quan | Thêm khóa trước, sau đó thao tác | Đảm bảo tính nhất quán mạnh, đơn giản và an toàn | Chi phí hiệu suất lớn | Ghi nhiều đọc ít, thao tác trong vùng critical tốn thời gian |
| Khóa lạc quan | Thao tác trước, kiểm tra khi cập nhật | Hiệu suất cao, không có chi phí khóa | Tồn tại vấn đề ABA, chi phí thử lại lớn khi cạnh tranh dữ dội | Đọc nhiều ghi ít, cạnh tranh không dữ dội |
| CAS | So sánh và hoán đổi (triển khai của khóa lạc quan) | Triển khai phần cứng, hiệu quả | Vấn đề ABA, chi phí tự quay | Triển khai thao tác nguyên tử, cấu trúc dữ liệu không khóa |
| Khóa tự quay | Thử lại liên tục khi thất bại (triển khai của khóa bi quan) | Tránh chi phí chuyển đổi luồng | Chiếm CPU không tải | Các kịch bản khóa được giữ trong thời gian cực ngắn |
| Khóa đọc-ghi | Đọc chia sẻ, ghi độc quyền | Cho phép nhiều luồng đọc đồng thời, cải thiện đáng kể hiệu suất đọc | Triển khai tương đối phức tạp, thao tác ghi có thể bị chết đói | Các kịch bản đồng thời đọc nhiều ghi ít |