Mở Đầu

Semaphore (còn được gọi là đèn giao thông, bảng tín hiệu, hay hệ thống đồng bộ) là một công cụ quan trọng trong lập trình đa luồng, giúp điều phối việc truy cập tài nguyên chung giữa các luồng (thread) hoặc tiến trình (process). Khi bạn muốn vẽ (render) các đối tượng đồ họa, hình ảnh, hoặc bất kỳ dữ liệu nào cần được cập nhật một cách an toàn trên một môi trường đa luồng, việc hiểu và áp dụng đúng cách vẽ trên semaphore là cực kỳ cần thiết. Bài viết này sẽ cung cấp một hướng dẫn chi tiết, từ những kiến thức cơ bản về semaphore, cách khởi tạo và sử dụng, cho đến các kỹ thuật vẽ nâng cao, tối ưu hoá hiệu suất và các ví dụ thực tế trong các ngôn ngữ lập trình phổ biến như C/C++, Java, Python và C#.

1. Semaphore là gì? Khái Niệm Cơ Bản

1.1 Định nghĩa

Semaphore là một cơ chế đồng bộ (synchronization primitive) được sử dụng để kiểm soát truy cập đến một tài nguyên chung. Nó hoạt động như một bộ đếm (counter) nguyên (integer) có thể tăng (signal/post) hoặc giảm (wait/acquire) và luôn luôn duy trì giá trị không âm.

  • Binary Semaphore (Semaphore nhị phân): Giá trị chỉ có thể là 0 hoặc 1, tương tự như một mutex (mutual exclusion). Thích hợp cho việc bảo vệ một tài nguyên duy nhất.
  • Counting Semaphore (Semaphore đếm): Giá trị có thể lớn hơn 1, cho phép đồng thời nhiều luồng truy cập vào một nhóm tài nguyên (ví dụ: một pool kết nối database với 5 kết nối).

1.2 Nguyên tắc hoạt động

  1. wait (P operation): Khi một luồng thực hiện wait, semaphore sẽ giảm giá trị của nó. Nếu giá trị sau khi giảm âm (<0), luồng sẽ bị chặn (blocked) cho đến khi có một luồng khác thực hiện signal.
  2. signal (V operation): Khi một luồng thực hiện signal, semaphore sẽ tăng giá trị của nó. Nếu có luồng đang chờ (blocked), một trong số chúng sẽ được thông báo (awaken) và tiếp tục thực thi.

1.3 Tại sao cần semaphore trong vẽ đồ họa?

Trong các ứng dụng đồ họa thời gian thực (game, GUI, simulation), việc vẽ (render) thường được thực hiện trên một luồng riêng (render thread) trong khi các luồng khác thực hiện tính toán logic, tải dữ liệu, hoặc xử lý sự kiện. Nếu các luồng này cùng truy cập và thay đổi cùng một bộ nhớ đồ họa (vertex buffer, texture, framebuffer…), có thể xảy ra:

  • Race condition: Kết quả vẽ không nhất quán, hình ảnh bị xáo trộn.
  • Deadlock: Hai luồng chờ nhau vô hạn.
  • Performance bottleneck: Gây tắc nghẽn do việc lock/unlock không hiệu quả.

Semaphore giúp đồng bộ thời gian cập nhật dữ liệu và thời gian vẽ, đảm bảo rằng khi render thread đang vẽ, dữ liệu không bị thay đổi, và ngược lại.

2. Kiến Trúc Cơ Bản Khi Vẽ Trên Semaphore

2.1 Mô hình Producer‑Consumer

Một cách nhìn nhận phổ biến là Producer‑Consumer:

  • Producer (Nhà sản xuất): Các luồng tính toán, tải tài nguyên, tạo vertex data, v.v. Chúng produce (sản xuất) dữ liệu mới và đưa vào một buffer chung.
  • Consumer (Người tiêu thụ): Render thread là consumer, nó consume (tiêu thụ) dữ liệu để vẽ lên màn hình.

Semaphore được dùng để:

  • Đảm bảo producer không ghi đè lên buffer khi consumer đang đọc.
  • Đảm bảo consumer không đọc dữ liệu chưa sẵn sàng.

2.2 Các thành phần cần thiết

Thành phần Vai trò Ví dụ
Shared Buffer Nơi lưu trữ dữ liệu đồ họa (vertex, index, texture) std::vector<Vertex> vertexBuffer;
Mutex Bảo vệ buffer khi cần đọc/ghi đồng thời std::mutex bufferMutex;
Counting Semaphore Đánh dấu số lượng buffer đã sẵn sàng để vẽ std::counting_semaphore<MAX_BUFFERS> readyBuffers;
Binary Semaphore Đánh dấu buffer đã được render và có thể ghi lại std::binary_semaphore freeBuffer;
Render Thread Thực hiện lệnh vẽ, lấy dữ liệu từ buffer void RenderLoop();
Worker Threads Tạo, cập nhật dữ liệu, đưa vào buffer void UpdateScene();

3. Cài Đặt Semaphore Trong Các Ngôn Ngữ Phổ Biến

3.1 C/C++ (POSIX & C++20)

3.1.1 POSIX Semaphore (Unix/Linux)

#include <semaphore.h>
#include <pthread.h>
#include <stdio.h> #define MAX_BUFFERS 3 sem_t freeBuffers; // Binary semaphore: số buffer rỗi
sem_t readyBuffers; // Counting semaphore: số buffer đã sẵn sàng void producer(void arg) { while (true) { sem_wait(&freeBuffers); // Đợi có buffer rỗi // ... tạo dữ liệu, ghi vào buffer sem_post(&readyBuffers); // Báo buffer đã sẵn sàng } return NULL;
} void consumer(void arg) { while (true) { sem_wait(&readyBuffers); // Đợi buffer ready // ... vẽ dữ liệu sem_post(&freeBuffers); // Đánh dấu buffer đã được render } return NULL;
} int main() { sem_init(&freeBuffers, 0, MAX_BUFFERS); // Khởi tạo với MAX_BUFFERS buffer rỗi sem_init(&readyBuffers, 0, 0); // Ban đầu không có buffer ready pthread_t prod, cons; pthread_create(&prod, NULL, producer, NULL); pthread_create(&cons, NULL, consumer, NULL); pthread_join(prod, NULL); pthread_join(cons, NULL); return 0;
}

3.1.2 C++20 std::counting_semaphore & std::binary_semaphore

#include <semaphore>
#include <thread>
#include <vector>
#include <iostream> constexpr int MAX_BUFFERS = 3;
std::counting_semaphore<MAX_BUFFERS> readyBuffers{0};
std::binary_semaphore freeBuffers{MAX_BUFFERS}; struct Vertex { float x, y, z; };
std::vector<std::vector<Vertex>> buffers(MAX_BUFFERS);
int writeIndex = 0, readIndex = 0; void producer() { while (true) { freeBuffers.acquire(); // chờ buffer rỗng // tạo dữ liệu bufferswriteIndex.clear(); bufferswriteIndex.push_back({0,0,0}); // ví dụ // ... readyBuffers.release(); // báo đã có dữ liệu writeIndex = (writeIndex + 1) % MAX_BUFFERS; }
} void consumer() { while (true) { readyBuffers.acquire(); // chờ dữ liệu sẵn sàng // vẽ dữ liệu const auto& data = buffersreadIndex; // ... call graphics API freeBuffers.release(); // buffer đã được dùng, trả lại readIndex = (readIndex + 1) % MAX_BUFFERS; }
} int main() { std::thread prod(producer); std::thread cons(consumer); prod.join(); cons.join();
}

3.2 Java (java.util.concurrent.Semaphore)

import java.util.concurrent.Semaphore;
import java.util.ArrayList;
import java.util.List; public class SemaphoreRender { private static final int MAX_BUFFERS = 3; private final Semaphore freeBuffers = new Semaphore(MAX_BUFFERS); private final Semaphore readyBuffers = new Semaphore(0); private final List<List<Vertex>> buffers = new ArrayList<>(MAX_BUFFERS); private int writeIdx = 0, readIdx = 0; static class Vertex { float x, y, z; Vertex(float x, float y, float z){ this.x=x; this.y=y; this.z=z; } } public SemaphoreRender() { for (int i = 0; i < MAX_BUFFERS; i++) buffers.add(new ArrayList<>()); } public void producer() { while (true) { try { freeBuffers.acquire(); // wait for free buffer List<Vertex> buf = buffers.get(writeIdx); buf.clear(); buf.add(new Vertex(0,0,0)); // generate data // ... readyBuffers.release(); // signal ready writeIdx = (writeIdx + 1) % MAX_BUFFERS; } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } } public void consumer() { while (true) { try { readyBuffers.acquire(); // wait for data List<Vertex> buf = buffers.get(readIdx); // render(buf); freeBuffers.release(); // buffer becomes free readIdx = (readIdx + 1) % MAX_BUFFERS; } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } } public static void main(String args) { SemaphoreRender sr = new SemaphoreRender(); new Thread(sr::producer).start(); new Thread(sr::consumer).start(); }
}

3.3 Python (threading.Semaphore)

Python GIL (Global Interpreter Lock) khiến đa luồng không thực sự song song trong CPU‑bound tasks, nhưng trong I/O hoặc khi gọi các thư viện C (OpenGL, Vulkan), semaphore vẫn hữu ích.

import threading
import time
import random MAX_BUFFERS = 3
free_buffers = threading.Semaphore(MAX_BUFFERS)
ready_buffers = threading.Semaphore(0) buffers = for _ in range(MAX_BUFFERS) write_idx = 0
read_idx = 0
lock = threading.Lock() # bảo vệ chỉ số def producer(): global write_idx while True: free_buffers.acquire() # tạo dữ liệu (giả sử một list các tuple) data = (random.random(), random.random(), random.random()) with lock: bufferswrite_idx = data write_idx = (write_idx + 1) % MAX_BUFFERS ready_buffers.release() time.sleep(0.01) # mô phỏng thời gian tính toán def consumer(): global read_idx while True: ready_buffers.acquire() with lock: data = buffersread_idx read_idx = (read_idx + 1) % MAX_BUFFERS # render(data) – gọi OpenGL/Vulkan ở đây free_buffers.release() time.sleep(0.016) # ~60 FPS threading.Thread(target=producer, daemon=True).start()
threading.Thread(target=consumer, daemon=True).start() # Để chương trình chạy vô hạn
while True: time.sleep(1)

3.4 C# (System.Threading.SemaphoreSlim)

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks; class SemaphoreRender { const int MAX_BUFFERS = 3; SemaphoreSlim freeBuffers = new SemaphoreSlim(MAX_BUFFERS, MAX_BUFFERS); SemaphoreSlim readyBuffers = new SemaphoreSlim(0, MAX_BUFFERS); List<List<Vertex>> buffers = new List<List<Vertex>>(MAX_BUFFERS); int writeIdx = 0, readIdx = 0; object idxLock = new object(); struct Vertex { public float X, Y, Z; public Vertex(float x,float y,float z){X=x;Y=y;Z=z;} } public SemaphoreRender(){ for(int i=0;i<MAX_BUFFERS;i++) buffers.Add(new List<Vertex>()); } async Task ProducerAsync(){ while(true){ await freeBuffers.WaitAsync(); var buf = bufferswriteIdx; buf.Clear(); buf.Add(new Vertex(0,0,0)); // generate data // ... readyBuffers.Release(); lock(idxLock){ writeIdx = (writeIdx+1)%MAX_BUFFERS; } } } async Task ConsumerAsync(){ while(true){ await readyBuffers.WaitAsync(); List<Vertex> buf; lock(idxLock){ buf = buffersreadIdx; readIdx = (readIdx+1)%MAX_BUFFERS; } // Render(buf) – gọi DirectX/OpenGL ở đây freeBuffers.Release(); } } public void Run(){ Task.Run(ProducerAsync); Task.Run(ConsumerAsync); } static void Main(){ var app = new SemaphoreRender(); app.Run(); Console.ReadLine(); // keep alive }
}

4. Chiến Lược Vẽ An Toàn Với Semaphore

4.1 Double Buffering (Hai Buffer)

Đây là mô hình hai buffer (front‑buffer & back‑buffer) phổ biến trong GUI và game engine:

  1. Back buffer: Được producer (logic thread) ghi dữ liệu.
  2. Front buffer: Được consumer (render thread) đọc và vẽ.
  3. Khi back buffer đã sẵn sàng, hai buffer hoán đổi vị trí (swap) dưới sự bảo vệ của semaphore.
// Pseudocode
binary_semaphore canSwap{0}; void producer(){ while(running){ // fill backBuffer canSwap.release(); // cho phép swap }
}
void consumer(){ while(running){ canSwap.acquire(); // chờ swap swap(frontBuffer, backBuffer); // render frontBuffer }
}

4.2 Triple Buffering (Ba Buffer)

Khi cần giảm độ trễ và tránh hiện tượng stutter, ba buffer cho phép producer luôn có một buffer trống ngay cả khi consumer đang vẽ. Semaphore sẽ quản lý số buffer đang dùng, đang chờ và đã sẵn sàng.

4.3 Lock‑Free Ring Buffer + Semaphore

Đối với yêu cầu hiệu suất cực cao (ví dụ: engine game 120 FPS), có thể dùng ring buffer không lock kết hợp semaphore để chỉ định trạng thái:

  • Head và Tail index được cập nhật atomically (std::atomic).
  • Semaphore chỉ quản lý số lượng phần tử có sẵn.
std::atomic<size_t> head{0}, tail{0};
std::counting_semaphore<MAX_BUFFERS> items{0};
std::binary_semaphore spaces{MAX_BUFFERS}; void producer(){ while(...){ spaces.acquire(); size_t pos = head.fetch_add(1) % MAX_BUFFERS; bufferpos = produceData(); items.release(); }
}
void consumer(){ while(...){ items.acquire(); size_t pos = tail.fetch_add(1) % MAX_BUFFERS; auto data = bufferpos; render(data); spaces.release(); }
}

4.4 Sử Dụng Semaphore Với GPU Queues

Trong các API đồ họa hiện đại (Vulkan, DirectX12), command queues và fences là cách đồng bộ GPU‑CPU. Semaphore vẫn có vai trò trong việc đồng bộ CPU side:

  • CPU Semaphore: Đảm bảo rằng CPU không ghi command buffer đang được GPU thực thi.
  • GPU‑CPU Semaphore: Được ánh xạ qua VkSemaphore (Vulkan) hoặc ID3D12Fence (DirectX12). Khi GPU hoàn thành, nó signal semaphore, cho phép CPU tiếp tục cập nhật.
// Vulkan example (pseudo)
VkSemaphore imageAvailableSem;
VkSemaphore renderFinishedSem;
vkCreateSemaphore(device, &createInfo, nullptr, &imageAvailableSem);
vkCreateSemaphore(device, &createInfo, nullptr, &renderFinishedSem); // submit
VkSubmitInfo submitInfo{};
submitInfo.waitSemaphoreCount = 1;
submitInfo.pWaitSemaphores = &imageAvailableSem;
submitInfo.signalSemaphoreCount = 1;
submitInfo.pSignalSemaphores = &renderFinishedSem;
vkQueueSubmit(graphicsQueue, 1, &submitInfo, nullptr);

5. Những Sai Lầm Thường Gặp Khi Dùng Semaphore Trong Vẽ

Sai lầm Hậu quả Cách khắc phục
Sử dụng semaphore thay cho mutex cho dữ liệu chia sẻ Race condition khi nhiều luồng vừa wait vừa signal mà không bảo vệ dữ liệu Dùng mutex hoặc std::lock_guard cho phần dữ liệu, semaphore chỉ dùng để đồng bộ trạng thái
Giải phóng (release) semaphore quá sớm Consumer có thể đọc dữ liệu chưa hoàn thiện → hiện tượng “tearing” Đảm bảo signal chỉ được gọi sau khi dữ liệu đã được ghi đầy đủ và đồng bộ bộ nhớ (std::atomic_thread_fence)
Không xử lý lỗi (InterruptedException) Thread có thể chết mà không giải phóng semaphore → deadlock Bắt và xử lý ngoại lệ, hoặc sử dụng try_acquire với timeout
Sử dụng semaphore đếm không giới hạn Bộ đếm có thể tăng vô hạn, dẫn tới resource leak Đặt giới hạn (template param trong C++20) hoặc kiểm tra giá trị trước khi release
Thiết kế vòng lặp chờ (busy‑wait) thay vì wait Lãng phí CPU, giảm FPS Luôn dùng blocking wait/acquire thay cho while(!ready){}

6. Tối Ưu Hóa Hiệu Suất

6.1 Tránh “False Sharing”

Khi các biến semaphore nằm ở cùng cache line với dữ liệu đồ họa, CPU có thể gây cache thrashing. Giải pháp:

  • Đặt semaphore và dữ liệu trong các struct alignas(64) để tách cache line.
  • Sử dụng padding.
struct alignas(64) SyncPrimitives { std::binary_semaphore freeBuffers; std::counting_semaphore<MAX_BUFFERS> readyBuffers; char pad64 - sizeof(std::binary_semaphore) - sizeof(std::counting_semaphore<MAX_BUFFERS>);
};

6.2 Batch Signal/Wait

Nếu một producer tạo ra nhiều khung hình liên tiếp, có thể batch việc release nhiều lần để giảm overhead hệ thống:

readyBuffers.release(batchSize); // C++20 cho phép release(n)

6.3 Sử Dụng std::atomic Khi Thích Hợp

Trong vòng lặp render tốc độ cao, thay vì semaphore cho mỗi frame, có thể dùng atomic flag để đánh dấu “frame ready”, còn semaphore chỉ dùng cho initialization hoặc shutdown.

std::atomic<bool> frameReady{false}; void producer(){ while(running){ // generate frameReady.store(true, std::memory_order_release); }
}
void consumer(){ while(running){ while(!frameReady.load(std::memory_order_acquire)) std::this_thread::yield(); // render frameReady.store(false, std::memory_order_release); }
}

6.4 Profiling & Debugging

  • Trace: Sử dụng strace, perf (Linux) hoặc Windows Performance Analyzer để theo dõi thời gian chờ semaphore.
  • Deadlock detection: Công cụ như ThreadSanitizer (TSan) hoặc Helgrind sẽ cảnh báo nếu semaphore không được giải phóng.
  • Metrics: Ghi lại wait_time, hold_time để tối ưu hoá kích thước buffer.

7. Ví Dụ Thực Tế: Xây Dựng Mini Engine Với Triple Buffering

7.1 Kiến Trúc Tổng Quan

+-------------------+ +-------------------+
| Logic Thread(s) | ----> | Triple Buffers |
+-------------------+ +-------------------+ | | | semaphore (freeBuffers) | v v
+-------------------+ +-------------------+
| Render Thread | <---- | GPU Command Queue|
+-------------------+ +-------------------+
  • FreeBuffers (binary semaphore, giá trị = 3) cho phép logic thread ghi vào bất kỳ buffer nào đang trống.
  • ReadyBuffers (counting semaphore) báo cho render thread có buffer nào sẵn sàng.
  • Khi render thread hoàn thành, nó signal freeBuffers để trả lại buffer.

7.2 Mã Nguồn C++ (Simplified)

#include <semaphore>
#include <thread>
#include <vector>
#include <iostream>
#include <chrono> constexpr int BUFFER_COUNT = 3;
std::binary_semaphore freeBuffers{BUFFER_COUNT};
std::counting_semaphore<BUFFER_COUNT> readyBuffers{0}; struct FrameData { std::vector<float> vertices; // có thể thêm texture, uniform, v.v.
}; std::vector<FrameData> buffers(BUFFER_COUNT);
int writeIdx = 0, readIdx = 0; void logicThread(){ int frame = 0; while(frame < 500){ // chạy 500 frame demo freeBuffers.acquire(); // chờ buffer rỗng auto& buf = bufferswriteIdx; buf.vertices.clear(); // tạo dữ liệu giả: một tam giác quay float angle = frame 0.01f; buf.vertices = { cos(angle), sin(angle), 0.0f, cos(angle+2.0f), sin(angle+2.0f), 0.0f, cos(angle+4.0f), sin(angle+4.0f), 0.0f }; readyBuffers.release(); // báo đã sẵn sàng writeIdx = (writeIdx + 1) % BUFFER_COUNT; ++frame; std::this_thread::sleep_for(std::chrono::milliseconds(5)); // mô phỏng tính toán }
} void renderThread(){ int rendered = 0; while(rendered < 500){ readyBuffers.acquire(); // chờ dữ liệu const auto& buf = buffersreadIdx; // ----- Begin GPU render (pseudo) ----- std::cout << "Render frame " << rendered << " vertices: " << buf.vertices.size()/3 << "\n"; // ----- End GPU render ----- freeBuffers.release(); // trả buffer readIdx = (readIdx + 1) % BUFFER_COUNT; ++rendered; std::this_thread::sleep_for(std::chrono::milliseconds(16)); // ~60 FPS }
} int main(){ std::thread logic(logicThread); std::thread render(renderThread); logic.join(); render.join(); return 0;
}

Kết quả: Bạn sẽ thấy console in ra “Render frame X vertices: 3” liên tục, cho thấy việc đồng bộ diễn ra trơn tru, không có frame nào bị mất hoặc bị trùng.

8. Khi Nào Nên Dùng Semaphore Thay Vì Các Cơ Chế Khác?

Trường hợp Công cụ thích hợp Lý do
Quản lý số lượng tài nguyên cố định (pool) Counting Semaphore Đếm tài nguyên, tự động block khi hết.
Đồng bộ trạng thái “có sẵn / đã dùng” giữa 2 luồng Binary Semaphore hoặc Mutex + Condition Variable Đơn giản, ít overhead.
Đồng bộ nhiều luồng producer/consumer Semaphore + Ring Buffer Cho phép đa luồng đồng thời, giảm contention.
Cần lock‑free, hiệu suất cực cao Atomic + Semaphore (đếm) Semaphore chỉ dùng để signal, phần lớn logic lock‑free.
Giao tiếp CPU‑GPU GPU‑specific semaphores/fences (Vulkan/DX12) Đảm bảo GPU hoàn thành trước khi CPU tiếp tục.

9. Các Thư Viện Hỗ Trợ Semaphore Cho Đồ Họa

Thư viện Ngôn ngữ Tính năng nổi bật
Boost.Interprocess C++ Semaphore có thể chia sẻ giữa tiến trình, hữu ích cho engine đa‑process.
OpenGL Sync Objects (GLsync) C/C++ Dùng glFenceSync/glWaitSync để đồng bộ GPU, tương tự semaphore.
Vulkan VkSemaphore C/C++ Semaphore cho queue submission, có thể liên kết với timeline semaphore (đếm).
DirectX12 ID3D12Fence C++ Fence hoạt động như binary hoặc timeline semaphore.
Qt QSemaphore C++ (Qt) Dễ dùng trong GUI apps, tích hợp với QThread.
Java java.util.concurrent.Semaphore Java Được sử dụng trong Android OpenGL ES apps.
C# System.Threading.SemaphoreSlim C# Thích hợp cho Unity hoặc WPF (DirectX) apps.

10. Kết Luận

Việc vẽ trên semaphore không phải là một khái niệm mơ hồ mà là một kỹ thuật thực tiễn, giúp các nhà phát triển đồ họa, game engineer, và lập trình viên hệ thống đồng bộ hoá một cách an toàn và hiệu quả giữa các luồng tính toán và render. Bằng cách:

  1. Hiểu rõ nguyên tắc của semaphore (wait / signal, binary vs counting).
  2. Áp dụng mô hình Producer‑Consumer qua buffer chung.
  3. Lựa chọn đúng loại semaphore và kết hợp với mutex, atomic hoặc lock‑free structures.
  4. Triển khai đúng cách trong ngôn ngữ và API bạn đang dùng (C/C++, Java, Python, C# và các API đồ họa như Vulkan, DirectX12).
  5. Tránh các sai lầm phổ biến và tối ưu hoá để giảm overhead và tránh deadlock.

Bạn sẽ có một nền tảng vững chắc để xây dựng các hệ thống đồ họa đa luồng mạnh mẽ, từ các ứng dụng GUI đơn giản đến các engine game phức tạp chạy ở tốc độ 120‑240 FPS. Hãy thực hành với các ví dụ trên, đo lường hiệu năng, và dần dần mở rộng sang các kỹ thuật nâng cao như timeline semaphore, GPU‑CPU fences, và lock‑free ring buffers để đạt được mức tối ưu cao nhất.

Chúc bạn thành công trong việc triển khai và tối ưu hoá các hệ thống vẽ đa luồng bằng semaphore!

Rate this post
Mục nhập này đã được đăng trong Blog. Đánh dấu trang permalink.

Để lại một bình luận

Email của bạn sẽ không được hiển thị công khai. Các trường bắt buộc được đánh dấu *