Loop ไม่เห็นค่าที่เธรดอื่นเปลี่ยนแปลงโดยไม่มีคำสั่งพิมพ์


93

ในรหัสของฉันฉันมีลูปที่รอให้สถานะบางอย่างเปลี่ยนจากเธรดอื่น เธรดอื่นใช้งานได้ แต่ลูปของฉันไม่เห็นค่าที่เปลี่ยนแปลง มันรอตลอดไป อย่างไรก็ตามเมื่อฉันใส่System.out.printlnคำสั่งในลูปมันก็ใช้งานได้ทันที! ทำไม?


ต่อไปนี้เป็นตัวอย่างรหัสของฉัน:

class MyHouse {
    boolean pizzaArrived = false;

    void eatPizza() {
        while (pizzaArrived == false) {
            //System.out.println("waiting");
        }

        System.out.println("That was delicious!");
    }

    void deliverPizza() {
        pizzaArrived = true;
    }
}

ในขณะที่ลูปในขณะทำงานฉันเรียกใช้deliverPizza()จากเธรดอื่นเพื่อตั้งค่าpizzaArrivedตัวแปร แต่การวนซ้ำจะใช้งานได้ก็ต่อเมื่อฉันไม่ใส่System.out.println("waiting");คำสั่ง เกิดอะไรขึ้น?

คำตอบ:


157

JVM ได้รับอนุญาตให้สมมติว่าเธรดอื่นไม่เปลี่ยนpizzaArrivedตัวแปรระหว่างลูป กล่าวอีกนัยหนึ่งก็คือสามารถยกการpizzaArrived == falseทดสอบนอกลูปได้โดยปรับสิ่งนี้ให้เหมาะสม:

while (pizzaArrived == false) {}

ในสิ่งนี้:

if (pizzaArrived == false) while (true) {}

ซึ่งเป็นวงวนที่ไม่มีที่สิ้นสุด

เพื่อให้แน่ใจว่าการเปลี่ยนแปลงที่ทำโดยเธรดหนึ่งจะมองเห็นได้กับเธรดอื่น ๆ คุณต้องเพิ่มการซิงโครไนซ์ระหว่างเธรดเสมอ วิธีที่ง่ายที่สุดคือสร้างตัวแปรที่ใช้ร่วมกันvolatile:

volatile boolean pizzaArrived = false;

การสร้างตัวแปรจะvolatileรับประกันได้ว่าเธรดที่แตกต่างกันจะเห็นผลของการเปลี่ยนแปลงของกันและกัน สิ่งนี้จะป้องกัน JVM จากการแคชค่าpizzaArrivedหรือยกการทดสอบนอกลูป แต่จะต้องอ่านค่าของตัวแปรจริงทุกครั้ง

(อย่างเป็นทางการมากขึ้นคือvolatileสร้างความสัมพันธ์ที่เกิดขึ้นก่อนระหว่างการเข้าถึงตัวแปรซึ่งหมายความว่างานอื่น ๆ ทั้งหมดที่เธรดทำก่อนส่งพิซซ่าจะปรากฏให้เห็นเธรดที่รับพิซซ่าแม้ว่าการเปลี่ยนแปลงอื่น ๆ เหล่านั้นจะไม่ใช่volatileตัวแปรก็ตาม)

วิธีการซิงโครไนซ์ใช้เป็นหลักในการใช้การยกเว้นซึ่งกันและกัน (ป้องกันสองสิ่งที่เกิดขึ้นในเวลาเดียวกัน) แต่ก็มีผลข้างเคียงเหมือนกันทั้งหมดที่volatileมี การใช้เมื่ออ่านและเขียนตัวแปรเป็นอีกวิธีหนึ่งในการทำให้เธรดอื่นมองเห็นการเปลี่ยนแปลง:

class MyHouse {
    boolean pizzaArrived = false;

    void eatPizza() {
        while (getPizzaArrived() == false) {}
        System.out.println("That was delicious!");
    }

    synchronized boolean getPizzaArrived() {
        return pizzaArrived;
    }

    synchronized void deliverPizza() {
        pizzaArrived = true;
    }
}

ผลกระทบของคำสั่งการพิมพ์

System.outเป็นPrintStreamวัตถุ วิธีการPrintStreamซิงโครไนซ์ดังนี้:

public void println(String x) {
    synchronized (this) {
        print(x);
        newLine();
    }
}

การซิงโครไนซ์จะป้องกันไม่ให้pizzaArrivedถูกแคชระหว่างลูป พูดอย่างเคร่งครัดเธรดทั้งสองต้องซิงโครไนซ์บนวัตถุเดียวกันเพื่อรับประกันว่าการเปลี่ยนแปลงของตัวแปรจะมองเห็นได้ (ตัวอย่างเช่นการเรียกprintlnหลังจากการตั้งค่าpizzaArrivedและเรียกอีกครั้งก่อนที่จะอ่านpizzaArrivedจะถูกต้อง) หากมีเธรดเดียวที่ซิงโครไนซ์กับอ็อบเจ็กต์เฉพาะ JVM จะได้รับอนุญาตให้เพิกเฉย ในทางปฏิบัติ JVM ไม่ฉลาดพอที่จะพิสูจน์ว่าเธรดอื่น ๆ จะไม่เรียกprintlnหลังจากการตั้งค่าpizzaArrivedดังนั้นจึงถือว่าพวกเขาอาจ System.out.printlnดังนั้นจึงไม่สามารถแคชตัวแปรในช่วงห่วงถ้าคุณโทร นั่นเป็นเหตุผลว่าทำไมการวนซ้ำจึงทำงานเช่นนี้เมื่อมีคำสั่งพิมพ์แม้ว่าจะไม่ใช่การแก้ไขที่ถูกต้องก็ตาม

การใช้System.outไม่ใช่วิธีเดียวที่จะทำให้เกิดเอฟเฟกต์นี้ แต่เป็นวิธีที่ผู้คนค้นพบบ่อยที่สุดเมื่อพวกเขาพยายามแก้จุดบกพร่องว่าเหตุใดการวนซ้ำจึงไม่ทำงาน!


ปัญหาที่ใหญ่กว่า

while (pizzaArrived == false) {}เป็นห่วงที่ไม่ว่างรอ เลวร้าย! ในขณะที่กำลังรอมันจะปล่อย CPU ซึ่งจะทำให้แอปพลิเคชั่นอื่น ๆ ทำงานช้าลงและเพิ่มการใช้พลังงานอุณหภูมิและความเร็วพัดลมของระบบ ตามหลักการแล้วเราต้องการให้เธรดลูปเข้าสู่โหมดสลีปในขณะที่รอดังนั้นจึงไม่ทำให้ CPU ค้าง

วิธีดำเนินการดังต่อไปนี้:

ใช้การรอ / แจ้งเตือน

วิธีแก้ปัญหาระดับต่ำคือการใช้วิธีการรอ / แจ้งเตือนของObject :

class MyHouse {
    boolean pizzaArrived = false;

    void eatPizza() {
        synchronized (this) {
            while (!pizzaArrived) {
                try {
                    this.wait();
                } catch (InterruptedException e) {}
            }
        }

        System.out.println("That was delicious!");
    }

    void deliverPizza() {
        synchronized (this) {
            pizzaArrived = true;
            this.notifyAll();
        }
    }
}

ในรหัสเวอร์ชันนี้การเรียกเธรดแบบวนซ้ำwait()ซึ่งทำให้เธรดเข้าสู่โหมดสลีป จะไม่ใช้รอบ CPU ใด ๆ ในขณะนอนหลับ หลังจากเธรดที่สองตั้งค่าตัวแปรมันจะเรียกnotifyAll()ใช้เธรดใด ๆ / ทั้งหมดที่รออยู่บนอ็อบเจ็กต์นั้น นี่ก็เหมือนกับการที่คนทำพิซซ่ากดกริ่งประตูเพื่อให้คุณสามารถนั่งพักระหว่างรอแทนที่จะยืนงุ่มง่ามที่ประตู

เมื่อโทรรอ / แจ้งเตือนบนวัตถุคุณต้องล็อคการซิงโครไนซ์ของวัตถุนั้นไว้ซึ่งเป็นสิ่งที่รหัสข้างต้นทำ คุณสามารถใช้วัตถุใดก็ได้ที่คุณต้องการตราบเท่าที่ทั้งสองเธรดใช้วัตถุเดียวกัน: ที่นี่ฉันใช้this(ตัวอย่างของMyHouse) โดยปกติเธรดสองเธรดจะไม่สามารถเข้าสู่บล็อกที่ซิงโครไนซ์ของอ็อบเจ็กต์เดียวกันพร้อมกันได้ (ซึ่งเป็นส่วนหนึ่งของจุดประสงค์ของการซิงโครไนซ์) แต่ทำงานได้ที่นี่เนื่องจากเธรดจะคลายล็อกการซิงโครไนซ์ชั่วคราวเมื่ออยู่ในwait()เมธอด

การปิดกั้นคิว

A BlockingQueueใช้เพื่อใช้คิวผู้ผลิต - ผู้บริโภค "ผู้บริโภค" นำรายการจากด้านหน้าของคิวและ "ผู้ผลิต" ผลักรายการที่ด้านหลัง ตัวอย่าง:

class MyHouse {
    final BlockingQueue<Object> queue = new LinkedBlockingQueue<>();

    void eatFood() throws InterruptedException {
        // take next item from the queue (sleeps while waiting)
        Object food = queue.take();
        // and do something with it
        System.out.println("Eating: " + food);
    }

    void deliverPizza() throws InterruptedException {
        // in producer threads, we push items on to the queue.
        // if there is space in the queue we can return immediately;
        // the consumer thread(s) will get to it later
        queue.put("A delicious pizza");
    }
}

หมายเหตุ: putและtakeวิธีการBlockingQueueโยนInterruptedExceptions ซึ่งได้รับการตรวจสอบข้อยกเว้นที่ต้องจัดการ ในโค้ดด้านบนเพื่อความเรียบง่ายข้อยกเว้นจะถูกเปลี่ยนใหม่ คุณอาจต้องการจับข้อยกเว้นในวิธีการและลองโทรใหม่หรือรับสายเพื่อให้แน่ใจว่าทำได้สำเร็จ นอกเหนือจากความน่าเกลียดจุดหนึ่งแล้วBlockingQueueยังใช้งานง่ายมาก

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

ผู้บริหาร

Executors ก็เหมือนสำเร็จรูปBlockingQueueที่สั่งงาน ตัวอย่าง:

// A "SingleThreadExecutor" has one work thread and an unlimited queue
ExecutorService executor = Executors.newSingleThreadExecutor();

Runnable eatPizza = () -> { System.out.println("Eating a delicious pizza"); };
Runnable cleanUp = () -> { System.out.println("Cleaning up the house"); };

// we submit tasks which will be executed on the work thread
executor.execute(eatPizza);
executor.execute(cleanUp);
// we continue immediately without needing to wait for the tasks to finish

สำหรับรายละเอียดดูสำหรับ doc Executor, และExecutorServiceExecutors

การจัดการเหตุการณ์

การวนซ้ำในขณะที่รอให้ผู้ใช้คลิกบางสิ่งใน UI นั้นผิด ให้ใช้คุณลักษณะการจัดการเหตุการณ์ของชุดเครื่องมือ UI แทน ใน Swingตัวอย่างเช่น:

JLabel label = new JLabel();
JButton button = new JButton("Click me");
button.addActionListener((ActionEvent e) -> {
    // This event listener is run when the button is clicked.
    // We don't need to loop while waiting.
    label.setText("Button was clicked");
});

เนื่องจากตัวจัดการเหตุการณ์ทำงานบนเธรดการจัดส่งเหตุการณ์การทำงานที่ยาวนานในตัวจัดการเหตุการณ์จะบล็อกการโต้ตอบอื่น ๆ กับ UI จนกว่างานจะเสร็จสิ้น การดำเนินการที่ช้าสามารถเริ่มต้นบนเธรดใหม่หรือส่งไปยังเธรดที่รออยู่โดยใช้หนึ่งในเทคนิคข้างต้น (รอ / แจ้งเตือน a BlockingQueueหรือExecutor) คุณยังสามารถใช้ a SwingWorkerซึ่งได้รับการออกแบบมาสำหรับสิ่งนี้และจัดหาเธรดผู้ปฏิบัติงานเบื้องหลังโดยอัตโนมัติ:

JLabel label = new JLabel();
JButton button = new JButton("Calculate answer");

// Add a click listener for the button
button.addActionListener((ActionEvent e) -> {

    // Defines MyWorker as a SwingWorker whose result type is String:
    class MyWorker extends SwingWorker<String,Void> {
        @Override
        public String doInBackground() throws Exception {
            // This method is called on a background thread.
            // You can do long work here without blocking the UI.
            // This is just an example:
            Thread.sleep(5000);
            return "Answer is 42";
        }

        @Override
        protected void done() {
            // This method is called on the Swing thread once the work is done
            String result;
            try {
                result = get();
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
            label.setText(result); // will display "Answer is 42"
        }
    }

    // Start the worker
    new MyWorker().execute();
});

ตัวจับเวลา

ในการดำเนินการเป็นระยะคุณสามารถใช้ไฟล์java.util.Timer. ใช้งานง่ายกว่าการเขียนไทม์มิ่งลูปของคุณเองและเริ่มและหยุดได้ง่ายกว่า การสาธิตนี้พิมพ์เวลาปัจจุบันหนึ่งครั้งต่อวินาที:

Timer timer = new Timer();
TimerTask task = new TimerTask() {
    @Override
    public void run() {
        System.out.println(System.currentTimeMillis());
    }
};
timer.scheduleAtFixedRate(task, 0, 1000);

แต่ละคนjava.util.Timerมีเธรดพื้นหลังของตัวเองซึ่งใช้ในการรันTimerTasks ตามกำหนดเวลา โดยปกติเธรดจะนอนระหว่างงานดังนั้นจึงไม่ทำให้ CPU ค้าง

ในรหัส Swing ยังมี a javax.swing.Timerซึ่งคล้ายกัน แต่เรียกใช้งาน Listener บน Swing thread ดังนั้นคุณสามารถโต้ตอบกับส่วนประกอบ Swing ได้อย่างปลอดภัยโดยไม่จำเป็นต้องสลับเธรดด้วยตนเอง:

JFrame frame = new JFrame();
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
Timer timer = new Timer(1000, (ActionEvent e) -> {
    frame.setTitle(String.valueOf(System.currentTimeMillis()));
});
timer.setRepeats(true);
timer.start();
frame.setVisible(true);

ทางอื่น

หากคุณกำลังเขียนโค้ดหลายเธรดคุณควรสำรวจคลาสในแพ็คเกจเหล่านี้เพื่อดูว่ามีอะไรบ้าง:

และดูส่วนการทำงานพร้อมกันของบทช่วยสอน Java มัลติเธรดมีความซับซ้อน แต่มีความช่วยเหลือมากมาย!


คำตอบที่เป็นมืออาชีพมากหลังจากอ่านสิ่งนี้แล้วไม่มีความเข้าใจผิดใด ๆ ในใจของฉันขอขอบคุณ
Humoyun Ahmad

1
คำตอบที่ยอดเยี่ยม ฉันทำงานกับเธรด Java มาระยะหนึ่งแล้วและยังคงได้เรียนรู้บางอย่างที่นี่ ( wait()ปลดล็อคการซิงโครไนซ์!)
brimborium

ขอบคุณ Boann! คำตอบที่ดีมันเหมือนกับบทความฉบับเต็มพร้อมตัวอย่าง! ใช่ยังชอบ "wait () ออกการล็อคการซิงโครไนซ์"
Kiryl Ivanou

java public class ThreadTest { private static boolean flag = false; private static class Reader extends Thread { @Override public void run() { while(flag == false) {} System.out.println(flag); } } public static void main(String[] args) { new Reader().start(); flag = true; } } @Boann รหัสนี้ไม่ได้ยกการpizzaArrived == falseทดสอบนอกลูปและลูปสามารถเห็นแฟล็กเปลี่ยนตามเธรดหลักทำไม?
gaussclb

1
@gaussclb ถ้าคุณหมายความว่าคุณถอดรหัสไฟล์คลาสให้ถูกต้อง คอมไพเลอร์ Java แทบไม่มีการปรับให้เหมาะสม การชักรอกทำโดย JVM คุณต้องแยกรหัสเครื่องดั้งเดิมออก ลอง: wiki.openjdk.java.net/display/HotSpot/PrintAssembly
Boann
โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.