Semaphore in Real World: ใช้ยังไงให้ระบบรอด ไม่ใช่แค่โค้ดรันได้
มีน้องคนหนึ่งมาถามผมใต้บทความเก่าเรื่อง Semaphore ใน Java
Semaphore in Real World: ใช้ยังไงให้ระบบรอด ไม่ใช่แค่โค้ดรันได้

มีน้องคนหนึ่งมาถามผมใต้บทความเก่าเรื่อง Semaphore ใน Java
บทความนั้นผมเขียนไว้นานมากแล้ว เป็นบทความพื้นฐาน อธิบายว่า Semaphore คืออะไร ใช้ยังไง ทำไมมันถึงจำกัดจำนวน Thread ที่เข้าไปทำงานพร้อมกันได้
ตอนนั้นผมอธิบายแบบง่าย ๆ ว่า
Semaphore เหมือนประตูที่มีบัตรผ่านอยู่จำนวนหนึ่ง ใครได้บัตรก็เข้าไปทำงานได้ ใครไม่ได้บัตรก็ต้องรอ
เช่น
Semaphore semaphore = new Semaphore(5);
แปลว่า อนุญาตให้มีงานเข้าไปทำพร้อมกันได้สูงสุด 5 งาน
โค้ดพื้นฐานประมาณนี้
semaphore.acquire();
try {
// do something with limited resource
} finally {
semaphore.release();
}
ดูง่ายมากครับ
แต่พอทำงานจริงมาหลายปี ผมเริ่มรู้สึกว่า Semaphore ไม่ใช่แค่เรื่อง syntax แล้ว
มันไม่ใช่แค่รู้ว่า acquire() คืออะไร
มันไม่ใช่แค่รู้ว่า release() ต้องใส่ใน finally
มันไม่ใช่แค่จำได้ว่า constructor ใส่ permit count ได้
ของจริงมันคือคำถามว่า
ระบบของเราควรปล่อยให้งานไหลเข้าไปใช้ resource พร้อมกันได้กี่งาน?
และคำถามนี้แหละครับ ที่ทำให้ Semaphore กลายเป็นเรื่อง design ไม่ใช่แค่เรื่องโค้ด
บทความนี้เลยอยากเล่าจากมุม Real World มากขึ้น
Semaphore ใช้แล้วรอดยังไง ใช้แล้วพังได้ยังไง ใช้ผิดแล้วระบบตายแบบเงียบ ๆ ยังไง และเมื่อไหร่ที่เราไม่ควรใช้ Semaphore แต่ควรใช้ Queue, Rate Limiter, Thread Pool หรือ Circuit Breaker แทน
1. Semaphore แก้ปัญหาอะไรในโลกจริง?
ในระบบจริง ปัญหาหนึ่งที่เจอบ่อยมากคือ
Request เยอะกว่าความสามารถของ resource
เช่น
- External API รับได้แค่ 10 concurrent calls
- Database connection มีจำกัด
- File processing หนักมาก ทำพร้อมกันเยอะแล้ว CPU พุ่ง
- Report generation กิน memory สูง
- Translation engine หรือ AI model มี instance จำกัด
- Third-party service rate limit ไม่ชัด แต่ถ้ายิงแรงไปจะเริ่ม timeout
- ระบบ downstream ช้ากว่าระบบเรา
ถ้าเราไม่คุมอะไรเลย สิ่งที่จะเกิดขึ้นคือทุก request จะวิ่งเข้าไปพร้อมกันหมด
ตอน traffic น้อย ทุกอย่างดูดีครับ
แต่พอ traffic เยอะขึ้นเท่านั้นแหละ
- Thread เริ่มรอสะสม
- Connection pool เต็ม
- CPU ขึ้น
- Memory ขึ้น
- Latency สูงขึ้น
- Timeout เริ่มมา
- Retry ซ้ำเข้าไปอีก
- ระบบ downstream พัง
- สุดท้ายระบบเราก็พังตาม
หลายครั้งปัญหาไม่ได้อยู่ที่ logic ผิด
แต่เกิดจากระบบปล่อย concurrent work เข้าไปมากเกินกว่าที่ resource รับไหว
Semaphore จึงเข้ามาช่วยในจุดนี้
มันช่วยตั้งกติกาง่าย ๆ ว่า
งานตรงนี้ เข้าได้พร้อมกันแค่นี้พอ ที่เหลือต้องรอ หรือถูกปฏิเสธอย่างมีเหตุผล
2. ตัวอย่างง่าย ๆ: จำกัดการเรียก External API
สมมติเรามี Service ที่ต้องเรียก External API ตัวหนึ่ง
External API นี้ไม่ได้บอก rate limit ชัดเจน แต่จาก production เราพบว่า ถ้าเรียกพร้อมกันเกิน 10 requests มันเริ่ม timeout
ถ้าเขียนตรง ๆ แบบไม่คุมอะไรเลย
public ExternalResponse callExternalApi(Request request) {
return webClient.post()
.uri("/external/api")
.bodyValue(request)
.retrieve()
.bodyToMono(ExternalResponse.class)
.block();
}
ถ้ามี 200 requests เข้ามาพร้อมกัน โค้ดนี้ก็มีโอกาสยิงออกไป 200 calls พร้อมกัน
ผลคือ external API อาจจะเริ่มช้า timeout หรือ block เรากลับมา
เราอาจใช้ Semaphore คุมไว้แบบนี้
@Component
public class ExternalApiClient {
private final Semaphore semaphore = new Semaphore(10);
private final WebClient webClient;
public ExternalApiClient(WebClient webClient) {
this.webClient = webClient;
}
public ExternalResponse callExternalApi(Request request) {
boolean acquired = false;
try {
acquired = semaphore.tryAcquire(2, TimeUnit.SECONDS);
if (!acquired) {
throw new ExternalApiBusyException("External API is currently busy");
}
return webClient.post()
.uri("/external/api")
.bodyValue(request)
.retrieve()
.bodyToMono(ExternalResponse.class)
.block();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ExternalApiBusyException("Interrupted while waiting for external API permit", e);
} finally {
if (acquired) {
semaphore.release();
}
}
}
}
จุดสำคัญของตัวอย่างนี้คือ ผมเลือกใช้ tryAcquire() พร้อม timeout แทน acquire() เฉย ๆ
เพราะในระบบ production เราไม่ควรให้ request รอแบบไม่มีกำหนดเสมอไป
ถ้ารอเกิน 2 วินาทีแล้วยังไม่ได้ permit แปลว่า resource ตรงนั้นยุ่งมากแล้ว
การตอบกลับว่า “ระบบส่วนนี้กำลัง busy” อาจดีกว่าปล่อยให้ thread รอจน timeout ชั้นนอก แล้วค่อยพังทีหลัง
3. Success Case: Semaphore ช่วยให้ระบบนิ่งขึ้น
Use case ที่ผมมองว่า Semaphore เหมาะมาก คือเวลาที่เรารู้ว่า resource หนึ่งมี capacity จำกัดชัดเจน
เช่น
- API ปลายทางรับ concurrent ได้ประมาณ 10
- Engine มี 4 instances
- File processor ทำพร้อมกันได้ไม่เกิน 3 งาน
- Report generation เปิดพร้อมกันเกิน 2 งานแล้ว memory พุ่ง
- Database operation บางชุดไม่ควร execute พร้อมกันมากเกินไป
ถ้าเรารู้ capacity แบบนี้ Semaphore จะช่วยให้ระบบ predictable ขึ้นมาก
ตัวอย่างเช่น เรามี engine อยู่ 4 ตัว
แทนที่จะปล่อยให้ request 100 ตัววิ่งเข้า engine พร้อมกัน เราตั้งกติกาว่า
Semaphore engineSemaphore = new Semaphore(4);
แปลว่า ไม่ว่า request จะเข้ามากี่ตัว งานที่เข้า engine จริง ๆ จะมีได้สูงสุด 4 งานพร้อมกัน
ระบบจะนิ่งขึ้น เพราะเราไม่ได้ให้โหลดจริงไหลเข้า resource แบบไร้การควบคุม
สิ่งที่ได้จาก Semaphore ใน case แบบนี้คือ
- จำกัดความเสียหาย
- ป้องกัน resource overload
- ทำให้ latency predictable ขึ้น
- ลด timeout cascade
- ลด retry storm
- ทำให้ระบบ downstream ยังพอหายใจได้
นี่คือจุดที่ Semaphore success มากครับ
มันเหมือนเราทำประตูกั้นน้ำ
น้ำจะมาเยอะแค่ไหนก็จริง แต่เราไม่ปล่อยให้มันทะลักเข้าเครื่องสูบน้ำทีเดียวทั้งหมด
4. Fail Case: ใช้ Semaphore แล้วระบบค้าง เพราะลืม release
ปัญหาคลาสสิกที่สุดของ Semaphore คือ
acquire แล้วไม่ release
เช่น
semaphore.acquire();
doSomething();
semaphore.release();
ดูเหมือนไม่มีอะไร
แต่ถ้า doSomething() throw exception ขึ้นมาก่อน release() จะไม่ถูกเรียก
permit ก็จะหายไปหนึ่งใบ
ถ้าเกิดซ้ำหลายครั้ง permit ก็จะค่อย ๆ หมด
สุดท้ายทุก thread จะรอ acquire แล้วระบบก็เหมือนค้าง ทั้งที่ CPU อาจไม่ได้สูงด้วยซ้ำ
นี่คือความน่ากลัวของมันครับ
ระบบไม่ได้ crash
แต่ระบบเหมือนหยุดหายใจ
ดังนั้น pattern ที่ควรจำให้ขึ้นใจคือ
boolean acquired = false;
try {
acquired = semaphore.tryAcquire(2, TimeUnit.SECONDS);
if (!acquired) {
throw new BusyException("Resource is busy");
}
doSomething();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new BusyException("Interrupted while waiting for permit", e);
} finally {
if (acquired) {
semaphore.release();
}
}
ทำไมต้องมี acquired?
เพราะถ้า tryAcquire() ไม่สำเร็จ เราไม่ควร release()
ถ้า release ทั้งที่ไม่ได้ acquire จะทำให้ permit เกินจำนวนที่ตั้งไว้
จากตั้งไว้ 10 อาจค่อย ๆ กลายเป็น 11, 12, 13 โดยไม่รู้ตัว
แล้วระบบก็จะคุม concurrency ไม่อยู่
5. Fail Case: ใช้ acquire() แบบรอไม่มีกำหนด
อีกปัญหาที่เจอบ่อยคือใช้แบบนี้
semaphore.acquire();
คำสั่งนี้จะรอจนกว่าจะได้ permit
ในบางระบบแบบ batch หรือ worker ภายใน อาจใช้ได้
แต่ใน request-response system เช่น REST API ต้องระวังมาก
เพราะถ้า traffic เยอะ แล้วทุก request มารอ permit หมด สิ่งที่เกิดขึ้นคือ
- Thread รอสะสม
- Queue ใน web server เต็ม
- Latency เพิ่ม
- Client timeout
- Load balancer timeout
- Retry เข้ามาเพิ่ม
- ระบบยิ่งหนักกว่าเดิม
สุดท้าย Semaphore ที่ตั้งใจจะช่วย อาจกลายเป็นจุดสะสม request ที่ทำให้ระบบตายช้าลง แต่ตายหนักขึ้น
ใน API layer ผมมักชอบใช้ tryAcquire() พร้อม timeout มากกว่า
if (!semaphore.tryAcquire(500, TimeUnit.MILLISECONDS)) {
throw new TooManyRequestsException("Too many concurrent requests");
}
แล้วตอบ HTTP 429 หรือ 503 ตาม context
เช่น
- 429 Too Many Requests ถ้าต้องการสื่อว่า request เยอะเกิน quota/concurrency ที่กำหนด
- 503 Service Unavailable ถ้าต้องการสื่อว่า service ส่วนนี้ไม่พร้อมชั่วคราว
ประเด็นคือ อย่าปล่อยให้รอแบบไร้ขอบเขตโดยไม่ตั้งใจ
ระบบที่ดีไม่ใช่ระบบที่รับทุกอย่างแล้วค่อยตาย
ระบบที่ดีคือระบบที่รู้ว่าเมื่อไหร่ควรปฏิเสธ เพื่อรักษาส่วนที่เหลือให้ยังทำงานได้
6. Fail Case: ตั้งจำนวน permit จากความรู้สึก
อีกจุดที่เจอบ่อยมากคือ
new Semaphore(100);
ถามว่าทำไม 100?
คำตอบคือ
น่าจะพอครับ
อันนี้อันตรายครับ
จำนวน permit ไม่ควรมาจากความรู้สึกอย่างเดียว
มันควรมาจาก capacity จริง เช่น
- ปลายทางรับ concurrent ได้เท่าไหร่
- connection pool มีขนาดเท่าไหร่
- CPU/memory รับ workload ได้เท่าไหร่
- latency เฉลี่ยของงานเป็นเท่าไหร่
- timeout ตั้งไว้เท่าไหร่
- throughput ที่ต้องการคือเท่าไหร่
- SLA/SLO ต้องการ response ภายในกี่ ms
ตัวอย่างเช่น External API ใช้เวลาตอบเฉลี่ย 500ms และเราปล่อย concurrent 10 calls
โดยประมาณ ระบบส่วนนี้อาจทำ throughput ได้ราว ๆ
10 concurrent calls / 0.5 second = 20 requests per second
ถ้า traffic จริงคือ 100 requests per second การตั้ง permit 10 อาจทำให้เกิด queue/wait เยอะ
แต่ถ้าตั้ง permit 100 ปลายทางอาจพัง
ดังนั้นค่า permit ไม่ใช่เลขสวย ๆ
มันคือ capacity decision
ต้องเริ่มจากค่าที่ปลอดภัยก่อน แล้ววัดจาก metric จริง เช่น
- wait time ก่อน acquire permit
- active permit count
- available permit count
- timeout count
- rejection count
- downstream latency
- downstream error rate
ถ้าไม่มี metric เราจะไม่รู้เลยว่า Semaphore กำลังช่วยระบบ หรือกำลังกลบปัญหาไว้เฉย ๆ
7. Semaphore ไม่ใช่ Queue
หลายคนใช้ Semaphore แล้วคิดว่ามันคือ Queue
จริง ๆ ไม่ใช่ครับ
Semaphore แค่คุมจำนวน concurrent access
แต่มันไม่ได้เก็บงานแบบ durable
ถ้า process ตาย งานที่รออยู่ใน memory ก็หาย
ถ้าเราต้องการระบบที่รับงานเข้ามา แล้วค่อย ๆ process ทีหลัง ควรคิดถึง Queue มากกว่า เช่น
- Kafka
- RabbitMQ
- Redis Stream
- SQS
- Database-backed job table
- In-memory queue สำหรับงาน local ที่ไม่ต้อง durable
ตัวอย่างเช่น upload file แล้วต้อง generate report หนัก ๆ
ถ้า user ไม่จำเป็นต้องรอผลทันที การใช้ Queue จะเหมาะกว่า Semaphore
Flow อาจเป็นแบบนี้
Client -> API -> Save Job -> Queue -> Worker -> Process -> Update Status
ส่วน Semaphore อาจถูกใช้ใน Worker อีกที เพื่อคุมว่า Worker process งานพร้อมกันได้ไม่เกินกี่งาน
เช่น
Queue มีงาน 10,000 งาน
แต่ Worker ใช้ Semaphore ให้ทำพร้อมกันแค่ 5 งาน
แบบนี้เหมาะกว่าเอา request ทั้งหมดมารอใน API thread
จำง่าย ๆ คือ
Semaphore คุมจำนวนงานที่กำลังทำ Queue เก็บงานที่รอทำ
สองตัวนี้ใช้ร่วมกันได้ แต่แทนกันไม่ได้เสมอไป
8. Semaphore ไม่ใช่ Rate Limiter
Semaphore กับ Rate Limiter คล้ายกันตรงที่ใช้ป้องกันระบบโดนถล่ม
แต่แก้คนละปัญหา
Semaphore คุมจำนวนงานที่ทำพร้อมกัน
Rate Limiter คุมจำนวนงานต่อช่วงเวลา
เช่น
Semaphore:
เข้าได้พร้อมกันสูงสุด 10 งาน
Rate Limiter:
ยิงได้สูงสุด 100 requests ต่อ 1 นาที
ถ้า External API จำกัดว่าเรียกได้ไม่เกิน 100 requests/minute การใช้ Semaphore อย่างเดียวอาจไม่พอ
เพราะแม้เราจะจำกัด concurrent ไว้ 10 แต่ถ้าแต่ละ call เร็วมาก เราอาจยิงเกิน 100 requests/minute ได้อยู่ดี
ในทางกลับกัน ถ้าใช้ Rate Limiter อย่างเดียว แต่ปลายทางรับ concurrent ได้แค่ 5 แล้วเรา burst 50 requests พร้อมกัน ก็ยังทำให้ปลายทาง overload ได้
ดังนั้นบางระบบอาจต้องใช้ทั้งคู่
Rate Limiter -> คุมจำนวนต่อเวลา
Semaphore -> คุมจำนวนพร้อมกัน
ตัวอย่างเช่น
- ไม่เกิน 100 requests/minute
- และพร้อมกันไม่เกิน 10 requests
นี่คือ design ที่เจอบ่อยในระบบที่ต้อง call third-party API
9. Semaphore ไม่ใช่ Circuit Breaker
Semaphore ป้องกันไม่ให้เราใช้ resource พร้อมกันเกินกำหนด
แต่ถ้าปลายทางพังไปแล้ว Semaphore ไม่ได้รู้เองว่าควรหยุดเรียก
เช่น External API เริ่มตอบ 500 ทุกครั้ง
Semaphore ยังอนุญาตให้เรียกได้ทีละ 10 อยู่เหมือนเดิม
มันไม่ได้ตัดวงจรให้
กรณีนี้ควรใช้ Circuit Breaker เช่น Resilience4j
Circuit Breaker จะช่วยดูว่า failure rate สูงเกินไปไหม ถ้าสูงเกินก็เปิด circuit แล้ว fail fast ชั่วคราว
ภาพรวมอาจเป็นแบบนี้
Client Request
-> Rate Limiter
-> Semaphore
-> Circuit Breaker
-> External API
แต่ลำดับจริง ๆ ขึ้นกับ design
บางระบบอาจให้ Circuit Breaker อยู่ก่อน Semaphore เพื่อไม่ต้องเสีย permit กับ call ที่รู้ว่า downstream กำลังพัง
บางระบบอาจให้ Semaphore ครอบ operation ทั้งก้อน
ประเด็นคือแต่ละตัวมีหน้าที่ต่างกัน
- Semaphore: คุม concurrent usage
- Rate Limiter: คุม request per time window
- Circuit Breaker: หยุดเรียกเมื่อปลายทางพัง
- Retry: ลองใหม่เมื่อ failure เป็น transient
- Timeout: จำกัดเวลารอ
- Bulkhead: แยก resource ไม่ให้พังลามกัน
อย่าเอา Semaphore ไปทำทุกอย่างครับ
มันเก่งในหน้าที่ของมัน แต่ไม่ได้เกิดมาเพื่อแก้ทุกปัญหา
10. ใช้ Semaphore กับ Virtual Threads ได้ไหม?
ได้ครับ แต่ต้องเข้าใจให้ถูก
Java Virtual Threads ทำให้การรอแบบ blocking ถูกลงมากเมื่อเทียบกับ platform threads
เช่น request หนึ่งต้องรอ permit จาก Semaphore
ถ้าเป็น platform thread การรอนั้นจะกิน thread จริง ๆ หนึ่งตัว
แต่ถ้าเป็น virtual thread การรอจะเบากว่า เพราะ virtual thread ถูกออกแบบมาให้รองรับ blocking I/O และ waiting ได้จำนวนมากกว่า
แต่นี่ไม่ได้แปลว่าเราควรปล่อยให้ virtual threads รอเป็นหมื่น ๆ แบบไม่คิดอะไร
Virtual Threads ทำให้การรอถูกลง
แต่ resource ปลายทางไม่ได้เก่งขึ้นตาม
External API ยังรับได้เท่าเดิม Database ยังมี connection เท่าเดิม Engine ยังมี instance เท่าเดิม CPU ยังมี core เท่าเดิม Memory ยังมีจำกัดเหมือนเดิม
ดังนั้น Semaphore ยังมีประโยชน์มากในยุค Virtual Threads
เพราะแม้เราจะรับ request ได้เยอะขึ้น แต่เรายังต้องคุมจำนวนงานที่เข้า resource จริง
ตัวอย่าง design ที่ผมชอบคือ
Virtual Threads -> รับ concurrent request ได้เยอะขึ้น
Semaphore -> คุม resource ที่มีจำกัด
Timeout -> ไม่ให้รอเกินควร
Metric -> ดูว่าคอขวดอยู่ตรงไหน
พูดง่าย ๆ คือ
Virtual Threads ช่วยให้รอได้ถูกลง Semaphore ช่วยไม่ให้ resource โดนถล่ม
สองตัวนี้อยู่ด้วยกันได้ดีมาก ถ้าเรา design ดี
11. Real Use Case: จำกัดจำนวน Engine Worker
สมมติเรามี Translation Engine อยู่ 4 instances
แต่ละ instance แปลได้ทีละงาน
ถ้า request หนึ่งแตกออกเป็นหลาย chunks เราไม่ควรส่งทุก chunk เข้า engine พร้อมกันแบบไม่จำกัด
ภาพง่าย ๆ คือ
Request A -> split 20 chunks
Request B -> split 20 chunks
Request C -> split 20 chunks
Total = 60 chunks
Engine instances = 4
ถ้าเราไม่คุมอะไรเลย chunk ทั้งหมดอาจแย่งกันเข้า engine จนระบบมั่ว
แนวทางที่เหมาะกว่าคือ
Incoming Request
-> Split into chunks
-> Put chunks into queue
-> Worker picks chunks
-> Semaphore/Pool controls max active engine usage
-> Merge results by original order
ใน case นี้ Semaphore อาจใช้คู่กับ Pool
เช่น
public class EnginePool {
private final Semaphore semaphore;
private final BlockingQueue<EngineInstance> availableEngines;
public EnginePool(List<EngineInstance> engines) {
this.semaphore = new Semaphore(engines.size());
this.availableEngines = new LinkedBlockingQueue<>(engines);
}
public EngineResult translate(Chunk chunk) {
boolean acquired = false;
EngineInstance engine = null;
try {
acquired = semaphore.tryAcquire(5, TimeUnit.SECONDS);
if (!acquired) {
throw new EngineBusyException("No engine available");
}
engine = availableEngines.take();
return engine.translate(chunk);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new EngineBusyException("Interrupted while waiting for engine", e);
} finally {
if (engine != null) {
availableEngines.offer(engine);
}
if (acquired) {
semaphore.release();
}
}
}
}
แต่ถ้าดูดี ๆ ใน case นี้เราอาจไม่จำเป็นต้องมีทั้ง Semaphore และ BlockingQueue ก็ได้ เพราะ Queue ของ engine instance เองก็บอกได้ว่าใครว่าง
นี่คือจุดที่ต้องคิดเรื่อง design
บางครั้ง Semaphore ช่วยให้โค้ดง่าย
บางครั้ง Pool abstraction ที่ดีอาจทำให้ไม่ต้องใช้ Semaphore ตรง ๆ
เช่น
EngineInstance engine = enginePool.borrow(timeout);
try {
return engine.translate(chunk);
} finally {
enginePool.release(engine);
}
แบบนี้ domain ชัดกว่า
เพราะคนอ่านไม่ต้องคิดเรื่อง permit แต่คิดเป็น “ยืม engine” และ “คืน engine”
12. Design Rule: อย่าให้ Semaphore กระจายเต็ม codebase
สิ่งหนึ่งที่ผมไม่ค่อยชอบคือการเห็น Semaphore ถูก new กระจายเต็ม project
เช่น
new Semaphore(10)
new Semaphore(5)
new Semaphore(20)
อยู่ตาม service ต่าง ๆ โดยไม่มีชื่อ ไม่มี metric ไม่มี config
ปัญหาคืออีก 6 เดือนข้างหน้า จะไม่มีใครรู้ว่าเลขพวกนี้มาจากไหน
ควรทำให้มันชัดเจนขึ้น เช่น
external-api:
max-concurrent-calls: 10
acquire-timeout-ms: 500
แล้วสร้าง component เฉพาะ
@Component
public class ExternalApiConcurrencyGuard {
private final Semaphore semaphore;
private final Duration acquireTimeout;
public ExternalApiConcurrencyGuard(ExternalApiProperties properties) {
this.semaphore = new Semaphore(properties.maxConcurrentCalls());
this.acquireTimeout = properties.acquireTimeout();
}
public <T> T execute(Supplier<T> supplier) {
boolean acquired = false;
try {
acquired = semaphore.tryAcquire(acquireTimeout.toMillis(), TimeUnit.MILLISECONDS);
if (!acquired) {
throw new TooManyRequestsException("External API concurrency limit exceeded");
}
return supplier.get();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new TooManyRequestsException("Interrupted while waiting for permit", e);
} finally {
if (acquired) {
semaphore.release();
}
}
}
}
แล้ว service ใช้แบบนี้
public ExternalResponse call(Request request) {
return concurrencyGuard.execute(() -> externalApiClient.call(request));
}
แบบนี้ดีกว่า เพราะ concurrency policy ถูกแยกออกมาเป็นของมันเอง
เวลาจะปรับเลขก็ปรับ config
เวลาจะใส่ metric ก็ใส่ที่เดียว
เวลาจะเปลี่ยนจาก Semaphore ไปใช้ Bulkhead ของ Resilience4j ก็เปลี่ยนที่เดียว
13. ต้องมี Metric ไม่งั้นเหมือนขับรถไม่มีหน้าปัด
ถ้าจะใช้ Semaphore ใน production ผมแนะนำว่าควรมี metric อย่างน้อยประมาณนี้
- จำนวน permit ทั้งหมด
- จำนวน permit ที่ใช้อยู่
- จำนวน permit ที่ว่าง
- จำนวนครั้งที่ acquire สำเร็จ
- จำนวนครั้งที่ acquire timeout
- ระยะเวลาที่รอก่อน acquire ได้
- จำนวน request ที่ถูก reject เพราะ concurrency เต็ม
- latency ของ operation ที่ถูกครอบด้วย Semaphore
- error rate ของ downstream/resource
เพราะถ้าไม่มี metric เราจะตอบไม่ได้เลยว่า
- permit น้อยไปไหม
- permit เยอะไปไหม
- resource ตันจริงไหม
- request รอนานแค่ไหน
- reject เยอะเพราะ traffic โต หรือเพราะ downstream ช้า
- ควร scale worker เพิ่ม หรือควรลด concurrency
ตัวอย่าง metric แบบง่าย ๆ
int availablePermits = semaphore.availablePermits();
int usedPermits = maxPermits - availablePermits;
แต่ในระบบจริงควรผูกกับ Micrometer เช่น gauge/counter/timer
Concept ประมาณนี้
Gauge.builder("external_api_semaphore_available_permits", semaphore, Semaphore::availablePermits)
.register(meterRegistry);
และนับ timeout
Counter acquireTimeoutCounter = Counter.builder("external_api_semaphore_acquire_timeout_total")
.register(meterRegistry);
สิ่งสำคัญคืออย่าใช้ Semaphore แบบมืด ๆ
เพราะเวลา production มีปัญหา เราต้องเห็นว่ามันรอที่ประตูนี้จริงไหม
14. Fairness: ต้องการเข้าตามคิวไหม?
Semaphore ใน Java สามารถสร้างแบบ fair ได้
Semaphore semaphore = new Semaphore(10, true);
true ตรงนี้หมายถึง fair mode
แนวคิดคือ thread ที่รอก่อน มีโอกาสได้ permit ก่อน ตามลำดับมากขึ้น
ดูเหมือนดีใช่ไหมครับ?
แต่ fair mode อาจมี cost ด้าน throughput
ในระบบที่ต้องการ performance สูง อาจไม่เปิด fair mode เสมอไป
คำถามคือ use case เราต้องการอะไร
ถ้าเป็น background worker ที่เน้น throughput อาจไม่ต้อง fair มาก
แต่ถ้าเป็นงานที่ต้องรักษาลำดับ หรือไม่อยากให้ request บางตัวอดตลอด fair mode อาจช่วยได้
อย่างไรก็ตาม fair mode ไม่ได้แทน queue design
ถ้าเราต้องการ ordering จริง ๆ ควรออกแบบ queue หรือ partitioning ให้ชัดเจนกว่า
15. ใช้ Semaphore ตรงไหนของระบบดี?
ตำแหน่งที่วาง Semaphore สำคัญมาก
วางผิดที่อาจไม่ช่วยอะไรเลย
ตัวอย่างเช่น ถ้าเราต้องการจำกัดการเรียก External API ควรวาง Semaphore ใกล้จุดที่ call external API จริง ๆ
ไม่ใช่วางไว้ที่ Controller แบบกว้าง ๆ โดยไม่รู้ว่า controller นั้นทำงานหลายอย่าง
หลักคิดคือ
Semaphore ควรครอบ resource ที่ต้องการป้องกัน ไม่ใช่ครอบมั่วทั้ง flow
ตัวอย่าง resource-specific guard
OrderController
-> OrderService
-> PaymentClient ใช้ paymentSemaphore
-> InventoryClient ใช้ inventorySemaphore
-> EmailClient ใช้ emailSemaphore
แบบนี้ดีกว่าใช้ semaphore ตัวเดียวครอบทั้ง OrderService
เพราะแต่ละ downstream มี capacity ไม่เท่ากัน
Payment อาจรับได้ 20 concurrent
Inventory อาจรับได้ 50 concurrent
Email อาจรับได้ 5 concurrent
ถ้าใช้ตัวเดียวครอบทั้งหมด เราอาจทำให้บาง resource ถูกจำกัดเกินไป หรือบาง resource ถูกปล่อยเกินไป
16. อย่าใช้ Semaphore เพื่อซ่อนปัญหา architecture
บางครั้งระบบช้าเพราะ design ไม่ดี
เช่น
- Query N+1
- Call external API ทีละ record
- ไม่มี cache
- ไม่มี async processing
- ไม่มี batch
- ทำงานหนักใน request thread
- ใช้ synchronous flow ทั้งที่ user ไม่จำเป็นต้องรอ
แล้วเราเอา Semaphore ไปครอบเพื่อให้ระบบไม่พัง
มันอาจช่วยลดความเสียหายได้ครับ
แต่ไม่ได้แก้ root cause
เช่น API หนึ่งต้องส่ง notification ให้ลูกค้า 10,000 คน
ถ้าเราใช้ Semaphore จำกัดให้ส่งทีละ 10 คน มันอาจไม่พัง
แต่คำถามคือ งานแบบนี้ควรอยู่ใน request-response API จริงไหม?
บางทีควรเปลี่ยนเป็น async job มากกว่า
API -> Create Notification Job -> Queue -> Worker -> Send Notification
Semaphore เป็นเครื่องมือที่ดี แต่ไม่ควรใช้เป็นผ้าห่มคลุม design ที่ผิด
17. Decision Guide: เมื่อไหร่ควรใช้ Semaphore?
ผมจะใช้ Semaphore เมื่อเงื่อนไขประมาณนี้ชัดเจน
- มี resource จำกัดจริง
- ต้องจำกัด concurrent usage ไม่ใช่แค่ rate ต่อเวลา
- งานนั้นยังควรทำแบบ synchronous หรือ local controlled worker
- รู้หรือประมาณ capacity ของ resource ได้
- ยอมรับ behavior ตอนเต็มได้ เช่น wait, timeout, reject
- มี metric เพื่อ monitor
- มี timeout และ fallback strategy
ตัวอย่างที่เหมาะ
จำกัด concurrent call ไป external API
จำกัด concurrent file processing
จำกัด concurrent report generation
จำกัด concurrent access ไป engine instance
จำกัด concurrent batch worker ใน process เดียว
จำกัด concurrent operation ที่กิน memory สูง
18. เมื่อไหร่ไม่ควรใช้ Semaphore?
ไม่ควรใช้ Semaphore เป็นคำตอบแรก ถ้าปัญหาจริงคือสิ่งเหล่านี้
ต้องการเก็บงานไว้ทำทีหลัง
ใช้ Queue ดีกว่า
ต้องการจำกัด request ต่อวินาทีหรือต่อนาที
ใช้ Rate Limiter ดีกว่า
Downstream พังบ่อย ต้องหยุดเรียกชั่วคราว
ใช้ Circuit Breaker ดีกว่า
ต้องการ retry transient failure
ใช้ Retry policy ดีกว่า
ต้องการแยก resource ไม่ให้ service หนึ่งลากอีก service พัง
ใช้ Bulkhead pattern ดีกว่า อาจ implement ด้วย Semaphore หรือ Thread Pool ก็ได้
ต้องการคุมจำนวน thread ที่ execute task
ใช้ ExecutorService หรือ ThreadPool configuration ดีกว่า
ต้องการ distributed concurrency limit ข้ามหลาย pod
Semaphore ใน memory ไม่พอ ต้องใช้ distributed lock/semaphore หรือ external coordinator เช่น Redis/Redisson, database, หรือระบบ queue ที่ออกแบบมาให้เหมาะ
19. เรื่องสำคัญ: Semaphore ธรรมดาคุมได้แค่ใน JVM เดียว
อันนี้สำคัญมาก
new Semaphore(10)
คุมได้แค่ใน process/JVM เดียว
ถ้า service เรามี 5 pods และแต่ละ pod มี Semaphore 10 permits
แปลว่าทั้งระบบอาจยิงพร้อมกันได้ 50 concurrent calls
ไม่ใช่ 10
Pod 1 = 10 permits
Pod 2 = 10 permits
Pod 3 = 10 permits
Pod 4 = 10 permits
Pod 5 = 10 permits
Total = 50 concurrent calls
ดังนั้นถ้าต้องการ limit ระดับ global ต้องคิดอีกแบบ
ทางเลือกเช่น
- แบ่ง quota ต่อ pod ให้เหมาะกับจำนวน replica
- ใช้ centralized rate limiter
- ใช้ Redis/Redisson distributed semaphore
- ใช้ queue แล้วกำหนดจำนวน consumer/worker
- ใช้ Kubernetes scaling + worker partitioning
- ใช้ API Gateway rate limiting
นี่เป็นจุดที่หลายคนพลาดครับ
ตอน local test มี pod เดียว ทุกอย่างดูดี
พอขึ้น production มี 10 pods limit คูณ 10 ทันที
20. Practical Checklist ก่อนใช้ Semaphore ใน Production
ก่อนจะใช้ Semaphore ผมอยากให้ตอบคำถามเหล่านี้ให้ได้
- Resource ที่เราจะป้องกันคืออะไร?
- Capacity จริงของ resource คือเท่าไหร่?
- จำนวน permit มาจากไหน?
- ถ้า permit เต็ม จะให้รอหรือ reject?
- ถ้ารอ รอได้กี่ milliseconds/seconds?
- ถ้า timeout จะตอบอะไรกลับ client?
- ต้อง retry ไหม หรือ retry จะทำให้แย่กว่าเดิม?
- ต้องมี fallback ไหม?
- มี metric ดู wait time และ rejection ไหม?
- ระบบรันกี่ pod? limit เป็น per pod หรือ global?
- ถ้า downstream ช้า Semaphore จะช่วยพอไหม หรือต้องมี Circuit Breaker?
- ถ้างานรอนาน ควรเปลี่ยนเป็น Queue ไหม?
- ต้องการ fairness ไหม?
- ถ้า permit leak จะ detect ยังไง?
- มี test case สำหรับ exception แล้ว release permit ไหม?
ถ้าตอบไม่ได้หลายข้อ แปลว่าอาจยังไม่ควรรีบใส่ Semaphore
ไม่ใช่เพราะ Semaphore ไม่ดี
แต่เพราะเรายังไม่รู้ว่าเรากำลังคุมอะไรอยู่
21. Testing Semaphore ต้องทดสอบอะไร?
อย่างน้อยควรมี test ประมาณนี้
Case 1: ทำงานสำเร็จแล้ว release permit
@Test
void shouldReleasePermitAfterSuccess() {
Semaphore semaphore = new Semaphore(1);
semaphore.acquireUninterruptibly();
semaphore.release();
assertEquals(1, semaphore.availablePermits());
}
Case 2: เกิด exception แล้วก็ต้อง release
@Test
void shouldReleasePermitEvenWhenExceptionOccurs() {
Semaphore semaphore = new Semaphore(1);
try {
semaphore.acquireUninterruptibly();
throw new RuntimeException("boom");
} catch (RuntimeException ignored) {
} finally {
semaphore.release();
}
assertEquals(1, semaphore.availablePermits());
}
Case 3: acquire ไม่ได้ภายใน timeout
@Test
void shouldTimeoutWhenPermitNotAvailable() throws Exception {
Semaphore semaphore = new Semaphore(1);
semaphore.acquire();
boolean acquired = semaphore.tryAcquire(100, TimeUnit.MILLISECONDS);
assertFalse(acquired);
semaphore.release();
}
แต่ใน production code จริง อย่าลืม pattern acquired boolean เพื่อกัน release เกิน
22. Pattern ที่ผมแนะนำ
ถ้าจะใช้ใน production ผมชอบ pattern ประมาณนี้
public final class ConcurrencyGuard {
private final Semaphore semaphore;
private final Duration acquireTimeout;
public ConcurrencyGuard(int maxConcurrent, Duration acquireTimeout) {
this.semaphore = new Semaphore(maxConcurrent);
this.acquireTimeout = acquireTimeout;
}
public <T> T execute(Supplier<T> supplier) {
boolean acquired = false;
try {
acquired = semaphore.tryAcquire(acquireTimeout.toMillis(), TimeUnit.MILLISECONDS);
if (!acquired) {
throw new ConcurrencyLimitExceededException("Concurrency limit exceeded");
}
return supplier.get();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ConcurrencyLimitExceededException("Interrupted while waiting for permit", e);
} finally {
if (acquired) {
semaphore.release();
}
}
}
public int availablePermits() {
return semaphore.availablePermits();
}
}
แล้วใช้แบบนี้
ExternalResponse response = externalApiGuard.execute(() -> externalApiClient.call(request));
ข้อดีคือ
- Business code ไม่ต้องรู้จัก Semaphore ตรง ๆ
- ลดโอกาสลืม release
- ใส่ metric ได้ในที่เดียว
- เปลี่ยน implementation ภายหลังได้ง่าย
- ทำให้ concurrency policy มีชื่อและมีความหมาย
23. สรุปแบบบ้าน ๆ
Semaphore เป็นเครื่องมือที่ดีมากครับ
แต่มันไม่ใช่ของวิเศษ
มันช่วยเราได้มากถ้าเราใช้เพื่อคุม resource ที่มีจำกัดจริง ๆ
แต่ก็ทำให้ระบบค้างได้ ถ้าใช้แบบไม่ระวัง
สิ่งที่อยากฝากคือ
อย่าใช้ Semaphore แค่ให้โค้ดรันได้ ให้ใช้เพื่อออกแบบให้ระบบรอด
ก่อนใช้ ให้ถามเสมอว่า
- เรากำลังป้องกัน resource อะไร?
- resource นั้นรับได้กี่ concurrent?
- ถ้าเต็มแล้วจะให้ request รอหรือ reject?
- มี timeout ไหม?
- มี metric ไหม?
- ระบบมีหลาย pod ไหม?
- ต้องใช้ Queue, Rate Limiter หรือ Circuit Breaker ร่วมด้วยไหม?
ถ้าตอบคำถามพวกนี้ได้ Semaphore จะไม่ใช่แค่ class ตัวหนึ่งใน Java
แต่มันจะกลายเป็นส่วนหนึ่งของ production design ที่ช่วยให้ระบบนิ่งขึ้นจริง ๆ
สุดท้าย ผมชอบมอง Semaphore แบบนี้ครับ
Semaphore ไม่ได้ทำให้ resource เราแรงขึ้น แต่มันช่วยไม่ให้เราใช้ resource เกินกว่าที่มันรับไหว
แค่นี้แหละครับ แต่ใน production แค่นี้สำคัญมากจริง ๆ
ระบบจำนวนมากไม่ได้พังเพราะโค้ดผิด
แต่มันพังเพราะเราไม่เคยตั้งประตูหน้าห้องให้มันเลย
พอทุกคนวิ่งเข้าไปพร้อมกันหมด ห้องมันก็แตกครับ
Semaphore คือประตูนั้น
แต่ประตูจะช่วยได้จริง ก็ต่อเมื่อเรารู้ว่าห้องนั้นรับคนได้กี่คน
메타데이터
- post_id
- bbaa33beb2ed
- slug
- semaphore-in-real-world-ใช้ยังไงให้ระบบรอด-ไม่ใช่แค่โค้ดรันได้-bbaa33beb2ed
- url
- https://medium.com/@zengcode/semaphore-in-real-world-%E0%B9%83%E0%B8%8A%E0%B9%89%E0%B8%A2%E0%B8%B1%E0%B8%87%E0%B9%84%E0%B8%87%E0%B9%83%E0%B8%AB%E0%B9%89%E0%B8%A3%E0%B8%B0%E0%B8%9A%E0%B8%9A%E0%B8%A3%E0%B8%AD%E0%B8%94-%E0%B9%84%E0%B8%A1%E0%B9%88%E0%B9%83%E0%B8%8A%E0%B9%88%E0%B9%81%E0%B8%84%E0%B9%88%E0%B9%82%E0%B8%84%E0%B9%89%E0%B8%94%E0%B8%A3%E0%B8%B1%E0%B8%99%E0%B9%84%E0%B8%94%E0%B9%89-bbaa33beb2ed
- canonical_url
- https://medium.com/@zengcode/semaphore-in-real-world-%E0%B9%83%E0%B8%8A%E0%B9%89%E0%B8%A2%E0%B8%B1%E0%B8%87%E0%B9%84%E0%B8%87%E0%B9%83%E0%B8%AB%E0%B9%89%E0%B8%A3%E0%B8%B0%E0%B8%9A%E0%B8%9A%E0%B8%A3%E0%B8%AD%E0%B8%94-%E0%B9%84%E0%B8%A1%E0%B9%88%E0%B9%83%E0%B8%8A%E0%B9%88%E0%B9%81%E0%B8%84%E0%B9%88%E0%B9%82%E0%B8%84%E0%B9%89%E0%B8%94%E0%B8%A3%E0%B8%B1%E0%B8%99%E0%B9%84%E0%B8%94%E0%B9%89-bbaa33beb2ed
- author_url
- https://medium.com/@zengcode
- status
- ok
- fetched_at
- 2026-07-11 17:33:52