กลับไปยังบทความ

Backend

Java Thread และ Multithreading: ตั้งแต่ Runnable จนถึง ExecutorService

เข้าใจการทำงานของ Java thread ตั้งแต่ Thread กับ Runnable, lambda, state ของ thread, race condition, การหยุด thread อย่างปลอดภัยด้วย interrupt หรือ flag แบบ volatile ไปจนถึง ExecutorService

24 กันยายน 256910 นาทีในการอ่าน

java threads and multithreading

Java thread คือเส้นทางการทำงานอิสระเส้นหนึ่งภายในโปรแกรม ทุกโปรแกรม Java เริ่มต้นด้วย thread เดียวคือตัวที่รัน main แล้วเราสร้าง thread เพิ่มได้เพื่อให้หลายงานเดินหน้าไปพร้อมกัน เช่น ดาวน์โหลดไฟล์ไปพร้อมกับที่หน้าจอยังตอบสนองได้ หรือให้ server รับ client หลายคนในเวลาเดียวกัน แต่ multithreading ก็มาพร้อมปัญหาใหม่ ข้อมูลที่ใช้ร่วมกันอาจเสียหาย และการหยุด thread ต้องทำให้ถูกวิธี

บทความนี้อธิบายความต่างระหว่าง process กับ thread, วิธีสร้าง thread สองแบบ (และทำไม Runnable มักเป็นตัวเลือกที่ดีกว่า), state ของ thread, การหยุด thread อย่างปลอดภัย, Timer เทียบกับ scheduled executor และ ExecutorService ซึ่งเป็นวิธีที่โค้ด Java ใน production ส่วนใหญ่ใช้รันงานพร้อมกัน ตัวอย่างใช้ Java 17 ขึ้นไป และมีหมายเหตุในจุดที่ Java 21 เปลี่ยนไป

การทำงานแบบลำดับกับแบบพร้อมกัน

โปรแกรมแบบ sequential (single task) แต่ละขั้นต้องรอขั้นก่อนหน้าเสร็จก่อน ถ้าขั้นที่สองเป็นการเรียกเครือข่ายที่ใช้เวลา 3 วินาที ทุกอย่างที่ตามมาก็ต้องรอ 3 วินาทีไปด้วย

ส่วนโปรแกรมแบบ multithread งานที่ไม่ขึ้นต่อกันจะรันบน thread แยกกัน บน CPU หลาย core มันรันขนานกันได้จริง ถ้ามี core เดียว OS จะสลับไปมาระหว่าง thread เร็วพอจนดูเหมือนทำงานพร้อมกัน

Process กับ thread ต่างกันอย่างไร

ProcessThread
Memoryมี address space ของตัวเองใช้ heap ร่วมกับ thread อื่นใน process เดียวกัน
ต้นทุนการสร้างสูงต่ำกว่ามาก
การสื่อสารPipe, socket, ไฟล์Object ที่แชร์กันใน memory
ถ้าพังProcess อื่นไม่ได้รับผลกระทบException ที่ไม่ถูกจับจะจบแค่ thread นั้น แต่ state ที่แชร์กันซึ่งเสียหายไปแล้วกระทบทุกคน

บางครั้ง thread ถูกเรียกว่า lightweight process หรือ execution context การที่ thread ใช้ memory ร่วมกันทำให้สื่อสารกันได้ถูก และก็เป็นต้นเหตุของ bug ด้าน concurrency ส่วนใหญ่ด้วย

การสร้าง Java thread

แบบที่ 1: extend Thread

java
public class MyThread extends Thread {
    private final String label;

    public MyThread(String label) {
        this.label = label;
    }

    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            System.out.println(label + ": " + i);
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return;
            }
        }
    }

    public static void main(String[] args) {
        new MyThread("A").start();
        new MyThread("B").start();
    }
}

ผลลัพธ์จะสลับกันไปมา (A: 0, B: 0, B: 1, A: 1...) และลำดับเปลี่ยนไปทุกครั้งที่รัน เพราะ thread scheduler เป็นคนตัดสินว่า thread ไหนได้ทำงานเมื่อไร อย่าเขียนโค้ดที่พึ่งลำดับแบบใดแบบหนึ่ง เว้นแต่จะประสานงานระหว่าง thread อย่างชัดเจน

แบบที่ 2: implement Runnable (แนะนำ)

Runnable แยก งาน ออกจาก thread ที่รันงาน:

java
public class Printer implements Runnable {
    private final String label;

    public Printer(String label) {
        this.label = label;
    }

    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            System.out.println(label + ": " + i + " on " + Thread.currentThread().getName());
        }
    }
}

Thread a = new Thread(new Printer("A"), "worker-a");
a.start();

เหตุผลที่ควรเลือก Runnable:

  • Class ของเรายัง extend class อื่นได้ เพราะ Java ให้มี superclass ได้แค่ตัวเดียว
  • งานเดียวกันนำไปรันบน Thread ธรรมดา, thread pool หรือ scheduler ได้โดยไม่ต้องแก้
  • ตัวงานโฟกัสที่หน้าที่ของมัน ไม่ต้องยุ่งกับการจัดการ thread

Anonymous class และ lambda

ถ้างานสั้น ๆ ไม่จำเป็นต้องตั้งชื่อ class Anonymous inner class ประกาศและสร้าง object ของ class ได้ในนิพจน์เดียว:

java
Thread c = new Thread(new Runnable() {
    @Override
    public void run() {
        System.out.println("C from an anonymous class");
    }
});

เนื่องจาก Runnable มี abstract method แค่ตัวเดียว lambda จึงทำแบบเดียวกันได้สั้นกว่า และเป็นรูปแบบที่เห็นในโค้ดสมัยใหม่:

java
Thread b = new Thread(() -> System.out.println("B says hi"), "worker-b");
b.start();

start() กับ run()

เรียก start() เสมอ มันจะสร้าง thread ใหม่ แล้ว thread นั้นค่อยเรียก run() ถ้าเราเรียก run() เอง method จะทำงานบน thread ปัจจุบันเหมือนเรียก method ทั่วไป ไม่มี thread ใหม่เกิดขึ้น และถ้าเรียก start() ซ้ำสองครั้งกับ Thread ตัวเดิมจะได้ IllegalThreadStateException

State ของ Java thread

ตำรามักอธิบายไว้ 5 state คือ New, Runnable, Running, Blocked, Dead แต่ enum Thread.State ของ Java จริง ๆ มี 6 ค่า และไม่มี state "running" แยกออกมา:

Thread.Stateความหมายชื่อในตำรา
NEWสร้างแล้ว แต่ยังไม่ได้เรียก start()New
RUNNABLEกำลังทำงาน หรือพร้อมและรอ CPU อยู่Runnable + Running
BLOCKEDรอเข้า block synchronized ที่ thread อื่นถือ lock อยู่Blocked
WAITINGรอแบบไม่มีกำหนด เช่น join() หรือ wait()Blocked
TIMED_WAITINGรอแบบมี timeout เช่น sleep(100)Blocked
TERMINATEDrun() ทำงานจบแล้วDead

ดู state ได้ด้วย thread.getState() ซึ่งช่วยตอน debug ส่วน join() ทำให้ thread ปัจจุบันรอจนอีก thread ทำงานจบ:

java
Thread a = new Thread(new Printer("A"));
System.out.println(a.getState()); // NEW
a.start();
a.join();                         // wait for a to finish
System.out.println(a.getState()); // TERMINATED

ข้อมูลที่แชร์กันและ race condition

เมื่อสอง thread อัปเดตตัวแปรเดียวกัน การอัปเดตบางครั้งอาจหายไป:

java
static int unsafeCount = 0;
static final AtomicInteger safeCount = new AtomicInteger();

Runnable task = () -> {
    for (int i = 0; i < 100_000; i++) {
        unsafeCount++;                 // read, add, write: not atomic
        safeCount.incrementAndGet();   // atomic
    }
};
// run task on two threads, then join both
// unsafeCount: often less than 200000
// safeCount:   always 200000

count++ มีสามขั้นตอนคือ อ่าน บวก แล้วเขียนกลับ สอง thread อาจอ่านได้ค่าเดียวกันแล้วเขียนผลเดียวกันกลับไป ทำให้การบวกหายไปหนึ่งครั้ง แก้ได้ด้วย AtomicInteger, block synchronized หรือ lock หรือดีที่สุดคือออกแบบให้ไม่ต้องแชร์ state ที่เปลี่ยนค่าได้ตั้งแต่แรก bug ประเภทเดียวกันในงานจริงอธิบายไว้ใน การป้องกัน race condition ในระบบชำระเงิน

วิธีหยุด Java thread อย่างปลอดภัย

Thread.stop() ถูก deprecate มาตั้งแต่ Java 1.2 และตั้งแต่ Java 20 จะ throw UnsupportedOperationException ทันที เหตุผลที่มันอันตรายคือมันฆ่า thread ณ จุดไหนก็ได้และปล่อย lock ทั้งหมดที่ถืออยู่ ทำให้ object ที่แชร์กันอาจค้างอยู่ในสภาพอัปเดตไม่ครบ วิธีที่ปลอดภัยคือแบบ cooperative เราขอให้ thread หยุด แล้ว thread ตรวจคำขอนั้นเองและจบการทำงานอย่างเรียบร้อย มีสองวิธีมาตรฐาน

ใช้ flag แบบ volatile

java
public class Poller implements Runnable {
    private volatile boolean running = true;

    public void stop() {
        running = false;
    }

    @Override
    public void run() {
        while (running) {
            pollOnce();
        }
        // clean up here, then return
    }

    private void pollOnce() { /* check for work */ }
}

volatile สำคัญมาก ถ้าไม่ใส่ worker thread อาจไม่เห็นค่าที่เปลี่ยนไปเลย เพราะ JIT อาจยกการอ่านค่าออกไปไว้นอก loop ข้อจำกัดของ flag คือมันไม่ปลุก thread ที่กำลัง sleep หรือรออยู่

ใช้ interrupt

thread.interrupt() จะตั้ง interrupt flag ของ thread ถ้า thread นั้นกำลัง block อยู่ใน sleep(), wait() หรือ join() คำสั่งนั้นจะ throw InterruptedException ออกมาทันที:

java
Thread worker = new Thread(() -> {
    while (!Thread.currentThread().isInterrupted()) {
        try {
            System.out.println("working...");
            Thread.sleep(500);
        } catch (InterruptedException e) {
            // sleep() cleared the interrupt flag when it threw; set it again
            Thread.currentThread().interrupt();
        }
    }
    System.out.println("worker cleaned up and exited");
});

worker.start();
Thread.sleep(1_200);
worker.interrupt();   // wakes the thread if it is sleeping
worker.join();        // wait for it to finish

กฎสองข้อ:

  1. อย่ากลืน InterruptedException ด้วย catch เปล่า ๆ ให้จบการทำงาน หรือคืนค่า flag ด้วย Thread.currentThread().interrupt() เพื่อให้โค้ดชั้นบนมองเห็น
  2. การอ่าน socket แบบดั้งเดิมไม่สนใจ interrupt เมื่อรันบน platform thread thread ที่ค้างอยู่ใน socket.getInputStream().read() จะไม่ตื่น ให้ปิด socket จาก thread อื่นแทน การอ่านจะ throw SocketException ออกมา เรื่องนี้ต้องระวังใน server อย่าง แอป chat ด้วย Java socket

Timer และ TimerTask เทียบกับ ScheduledExecutorService

Timer รัน TimerTask ครั้งเดียวหรือเป็นรอบ ๆ ตามช่วงเวลาที่กำหนด บน background thread ตัวเดียว:

java
Timer timer = new Timer("clock", true); // daemon: will not keep the JVM alive
timer.scheduleAtFixedRate(new TimerTask() {
    @Override
    public void run() {
        System.out.println("tick " + System.currentTimeMillis());
    }
}, 0, 1_000);
// later: timer.cancel();

ใช้งานได้ แต่มีจุดอ่อน ทุก task ใช้ thread เดียวกัน task ที่ช้าจึงทำให้ task อื่นเลื่อนออกไป และถ้า task ไหนเกิด exception ที่ไม่ถูกจับ thread ของ Timer จะตาย ทำให้ task ที่ตั้งเวลาไว้ทั้งหมดถูกยกเลิก โค้ดใหม่ควรใช้ ScheduledExecutorService:

java
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
scheduler.scheduleAtFixedRate(
        () -> System.out.println("tick " + System.currentTimeMillis()),
        0, 1, TimeUnit.SECONDS);
// later: scheduler.shutdown();

ExecutorService: วิธีมาตรฐานในการรันงาน

การสร้าง new Thread ทุกครั้งที่มีงานนั้นแพงและไม่มีเพดาน ExecutorService จัดการ thread pool ให้ เราแค่ส่งงานเข้าไป แล้วมันจะใช้ thread ชุดเดิมซ้ำ ๆ ในการรัน Callable คล้าย Runnable แต่คืนค่าได้ และเราได้ค่านั้นกลับมาผ่าน Future ในตัวอย่างนี้ fetchLength แทน blocking call ใด ๆ ก็ได้:

java
ExecutorService pool = Executors.newFixedThreadPool(4);
try {
    List<Callable<Integer>> tasks = new ArrayList<>();
    for (String url : List.of("/a", "/bb", "/ccc")) {
        tasks.add(() -> fetchLength(url));
    }
    for (Future<Integer> result : pool.invokeAll(tasks)) {
        System.out.println(result.get()); // blocks until that task is done
    }
} finally {
    pool.shutdown();                                   // stop accepting new tasks
    if (!pool.awaitTermination(10, TimeUnit.SECONDS)) {
        pool.shutdownNow();                            // interrupt tasks still running
    }
}

ต้อง shutdown executor ทุกครั้ง thread ของมันไม่ใช่ daemon thread ถ้าลืมปิด pool จะทำให้ JVM ไม่ยอมจบ และ shutdownNow() ทำงานผ่านการ interrupt นี่จึงเป็นอีกเหตุผลที่ task ของเราควรตอบสนองต่อ interrupt

ตั้งแต่ Java 21 ขึ้นไป virtual thread ทำให้การใช้ thread ต่อหนึ่งงานมีต้นทุนต่ำ แม้จะมีงานที่ block อยู่เป็นหมื่นงาน และตั้งแต่ Java 19 ExecutorService เป็น AutoCloseable แล้ว try-with-resources จึงรอให้งานทั้งหมดเสร็จให้เอง:

java
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 10_000; i++) {
        int id = i;
        executor.submit(() -> {
            Thread.sleep(1_000);
            return id;
        });
    }
} // close() waits for all tasks to finish
ความต้องการใช้อะไร
ฝึกเขียน หรืองาน background ครั้งเดียวnew Thread(runnable).start()
งานสั้น ๆ จำนวนมากExecutors.newFixedThreadPool(n)
งานที่ใช้ CPU หนักFixed pool ขนาดเท่ากับ Runtime.getRuntime().availableProcessors()
งาน I/O ที่ block จำนวนมาก (Java 21+)Executors.newVirtualThreadPerTaskExecutor()
งานที่ทำเป็นรอบหรือหน่วงเวลาScheduledExecutorService

ถ้าคุณเขียน Go ด้วย Go concurrency ใน production แสดงให้เห็นว่า goroutine และ channel จัดการปัญหาแบบเดียวกันนี้อย่างไร

คำถามที่พบบ่อย

Thread กับ Runnable ใน Java ต่างกันอย่างไร

Thread คือ object ที่รันโค้ดบนเส้นทางการทำงานแยกออกมา ส่วน Runnable คืองานที่จะรัน การ implement Runnable ทำให้ class ของเรายัง extend class อื่นได้ และส่งให้ executor รันได้

ถ้าเรียก run() แทน start() จะเกิดอะไรขึ้น

โค้ดจะทำงานบน thread ปัจจุบันเหมือนเรียก method ธรรมดา ไม่มี thread ใหม่ถูกสร้าง จึงไม่มีอะไรทำงานพร้อมกัน

จะหยุด thread ใน Java ได้อย่างไร

ขอให้มันหยุดแบบ cooperative ด้วย flag แบบ volatile หรือ interrupt() แล้วให้ thread ตรวจคำขอนั้นและจบการทำงานเอง อย่าใช้ Thread.stop() ซึ่งใช้ไม่ได้แล้วใน Java เวอร์ชันปัจจุบัน

แอป Java ควรมีกี่ thread

งานที่ใช้ CPU เป็นหลัก ใช้ประมาณเท่าจำนวน core ของ CPU ส่วนงานที่เน้น I/O ใช้มากกว่านั้นได้ เพราะ thread ใช้เวลาส่วนใหญ่ไปกับการรอ ตั้งแต่ Java 21 virtual thread ทำให้แทบไม่ต้องมานั่งปรับขนาด pool สำหรับงาน I/O อีก

Checklist สำหรับ Java thread

  • Implement Runnable หรือ Callable แล้วรันด้วย ExecutorService
  • เริ่ม thread ด้วย start() ไม่ใช่ run()
  • ป้องกัน state ที่แชร์และเปลี่ยนค่าได้ด้วย atomic, synchronized หรือ lock หรือเลี่ยงการแชร์ไปเลย
  • หยุด thread แบบ cooperative ด้วย flag แบบ volatile หรือ interrupt() ห้ามใช้ Thread.stop()
  • อย่ากลืน InterruptedException ให้จบการทำงานหรือคืนค่า flag
  • โค้ดใหม่ใช้ ScheduledExecutorService แทน Timer
  • Shutdown executor ทุกครั้ง หรือใช้ try-with-resources บน Java 19 ขึ้นไป

ถ้าทีมของคุณเจอปัญหา concurrency ในระบบ backend ที่เขียนด้วย Java หรือ Go ทีม Vectorkub ช่วย review และแก้ไขได้

พร้อมสร้างสิ่งที่ยิ่งใหญ่หรือยัง?

เล่าโปรเจคของคุณให้เราฟัง — เราจะพาคุณไปถึง

เลือกคุกกี้ที่เราใช้ได้ คุกกี้ที่จำเป็นช่วยให้เว็บไซต์ทำงานได้และเปิดใช้งานเสมอ