多執行緒設計模式:保護性暫停模式詳解以及其在DUBBO應用原始碼分析

IT徐胖子發表於2020-10-31

歡迎關注公眾號【JAVA前線】檢視精彩文章

1 文章概述

在多執行緒程式設計實踐中,我們肯定會面臨執行緒間資料互動的問題。在處理這類問題時需要使用一些設計模式,從而保證程式的正確性和健壯性。

保護性暫停設計模式就是解決多執行緒間資料互動問題的一種模式。本文先從基礎案例介紹保護性暫停基本概念和實踐,再由淺入深,最終分析DUBBO原始碼中保護性暫停設計模式使用場景。


2 什麼是保護性暫停

我們設想這樣一種場景:執行緒A生產資料,執行緒B讀取資料這個資料。

但是有一種情況:執行緒B準備讀取資料時,此時執行緒A還沒有生產出資料。

在這種情況下執行緒B不能一直空轉,也不能立即退出,執行緒B要等到生產資料完成並拿到資料之後才退出。

那麼在資料沒有生產出這段時間,執行緒B需要執行一種等待機制,這樣可以達到對系統保護目的,這就是保護性暫停。

保護性暫停有多種實現方式,本文我們用synchronized/wait/notify的方式實現。

@Getter
@Setter
public class MyData implements Serializable {
    private static final long serialVersionUID = 1L;
    private String message;

    public MyData(String message) {
        this.message = message;
    }
}

class Resource1 {
    private MyData data;
    private Object lock = new Object();

    public MyData getData() {
        synchronized (lock) {
            while (data == null) {
                try {
                    // 沒有資料則釋放鎖並暫停等待被喚醒
                    lock.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            return data;
        }
    }

    public void sendData(MyData data) {
        synchronized (lock) {
            // 生產資料後喚醒消費執行緒
            this.data = data;
            lock.notifyAll();
        }
    }
}

/**
 * 保護性暫停例項一
 *
 * @author 微信公眾號「JAVA前線」
 */
public class ProtectDesignTest1 {

    public static void main(String[] args) {
        Resource1 resource = new Resource1();
        new Thread(() -> {
            try {
                MyData data = new MyData("hello");
                System.out.println(Thread.currentThread().getName() + "生產資料=" + data);
                // 模擬傳送耗時
                TimeUnit.SECONDS.sleep(3);
                resource.sendData(data);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, "t1").start();

        new Thread(() -> {
            MyData data = resource.getData();
            System.out.println(Thread.currentThread().getName() + "接收到資料=" + data);
        }, "t2").start();
    }
}

在上述例項中執行緒1生產資料,執行緒2消費資料。Resource1類中通過wait/notify實現了保護性暫停設計模式。


3 加一個超時時間

上述例項中如果執行緒2沒有獲取到資料,那麼執行緒2直到拿到資料才會退出。

現在我們給獲取資料指定一個超時時間,如果在這個時間內沒有獲取到資料則丟擲超時異常。

雖然只是加一個引數,但是其中有很多細節需要注意。

3.1 一段有問題的程式碼

我們分析下面這段程式碼

class Resource2 {
    private MyData data;
    private Object lock = new Object();

    public MyData getData(int timeOut) {
        synchronized (lock) {
            while (data == null) {
                try {
                    // 程式碼1
                    lock.wait(timeOut);
                    break;
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            if (data == null) {
                throw new RuntimeException("超時未獲取到結果");
            }
            return data;
        }
    }

    public void sendData(MyData data) {
        synchronized (lock) {
            this.data = data;
            lock.notifyAll();
        }
    }
}


/**
 * 保護性暫停例項二
 *
 * @author 微信公眾號「JAVA前線」
 */
public class ProtectDesignTest2 {

    public static void main(String[] args) {
        Resource2 resource = new Resource2();
        new Thread(() -> {
            try {
                MyData data = new MyData("hello");
                System.out.println(Thread.currentThread().getName() + "生產資料=" + data);
                // 模擬傳送耗時
                TimeUnit.SECONDS.sleep(3);
                resource.sendData(data);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, "t1").start();

        new Thread(() -> {
            MyData data = resource.getData(1000);
            System.out.println(Thread.currentThread().getName() + "接收到資料=" + data);
        }, "t2").start();
    }
}

這段程式碼看似沒有問題,使用的也是wait帶有超時時間的引數,那麼問題可能出在哪裡呢?

問題是執行緒虛假喚醒帶來的。如果還沒有到timeOut時間程式碼1就被虛假喚醒,此時data還沒有值就會直接跳出迴圈,這樣沒有達到我們預期的timeOut才跳出迴圈的預期。

關於虛假喚醒這個概念,我們看看JDK官方文件相關介紹。

A thread can also wake up without being notified, interrupted, or timing out, a so-called spurious wakeup. While this will rarely occur in practice, applications must guard against it by testing for the condition that should have caused the thread to be awakened, and continuing to wait if the condition is not satisfied. In other words, waits should always occur in loops, like this one:

synchronized (obj) {
    while (<condition does not hold>)
        obj.wait(timeout);
}

官方文件告訴我們一個執行緒可能會在沒有被notify時虛假喚醒,所以判斷是否繼續wait時必須用while迴圈。我們在寫程式碼時一定也要注意執行緒虛假喚醒問題。

3.2 正確例項

上面我們明白了虛假喚醒問題,現在我們對程式碼進行修改,說明參看程式碼註釋。

class Resource3 {
    private MyData data;
    private Object lock = new Object();

    public MyData getData(int timeOut) {
        synchronized (lock) {
            // 執行時長
            long timePassed = 0;
            // 開始時間
            long begin = System.currentTimeMillis();
            // 如果結果為空
            while (data == null) {
                try {
                    // 如果執行時長大於超時時間退出迴圈
                    if (timePassed > timeOut) {
                        break;
                    }
                    // 如果執行時長小於超時時間表示虛假喚醒 -> 只需再等待時間差值
                    long waitTime = timeOut - timePassed;

                    // 等待時間差值
                    lock.wait(waitTime);

                    // 結果不為空直接返回
                    if (data != null) {
                        break;
                    }
                    // 被喚醒後計算執行時長
                    timePassed = System.currentTimeMillis() - begin;
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            if (data == null) {
                throw new RuntimeException("超時未獲取到結果");
            }
            return data;
        }
    }

    public void sendData(MyData data) {
        synchronized (lock) {
            this.data = data;
            lock.notifyAll();
        }
    }
}

/**
 * 保護性暫停例項三
 *
 * @author 微信公眾號「JAVA前線」
 */
public class ProtectDesignTest3 {

    public static void main(String[] args) {
        Resource3 resource = new Resource3();
        new Thread(() -> {
            try {
                MyData data = new MyData("hello");
                System.out.println(Thread.currentThread().getName() + "生產資料=" + data);
                // 模擬傳送耗時
                TimeUnit.SECONDS.sleep(3);
                resource.sendData(data);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, "t1").start();

        new Thread(() -> {
            MyData data = resource.getData(1000);
            System.out.println(Thread.currentThread().getName() + "接收到資料=" + data);
        }, "t2").start();
    }
}

4 加一個編號

現在再來設想一個場景:現在有三個生產資料的執行緒1、2、3,三個獲取資料的執行緒4、5、6,我們希望每個獲取資料執行緒都只拿到其中一個生產執行緒的資料,不能多拿也不能少拿。

這裡需要引入一個Futures模型,這個模型為每個資源進行編號並儲存在容器中,例如執行緒1生產的資料被拿走則從容器中刪除,一直到容器為空結束。

@Getter
@Setter
public class MyNewData implements Serializable {
    private static final long serialVersionUID = 1L;
    private static final AtomicLong ID = new AtomicLong(0);
    private Long id;
    private String message;

    public MyNewData(String message) {
        this.id = newId();
        this.message = message;
    }

    /**
     * 自增到最大值會回到最小值(負值可以作為識別ID)
     */
    private static long newId() {
        return ID.getAndIncrement();
    }

    public Long getId() {
        return this.id;
    }
}

class MyResource {
    private MyNewData data;
    private Object lock = new Object();

    public MyNewData getData(int timeOut) {
        synchronized (lock) {
            long timePassed = 0;
            long begin = System.currentTimeMillis();
            while (data == null) {
                try {
                    if (timePassed > timeOut) {
                        break;
                    }
                    long waitTime = timeOut - timePassed;
                    lock.wait(waitTime);
                    if (data != null) {
                        break;
                    }
                    timePassed = System.currentTimeMillis() - begin;
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            if (data == null) {
                throw new RuntimeException("超時未獲取到結果");
            }
            return data;
        }
    }

    public void sendData(MyNewData data) {
        synchronized (lock) {
            this.data = data;
            lock.notifyAll();
        }
    }
}

class MyFutures {
    private static final Map<Long, MyResource> FUTURES = new ConcurrentHashMap<>();

    public static MyResource newResource(MyNewData data) {
        final MyResource future = new MyResource();
        FUTURES.put(data.getId(), future);
        return future;
    }

    public static MyResource getResource(Long id) {
        return FUTURES.remove(id);
    }

    public static Set<Long> getIds() {
        return FUTURES.keySet();
    }
}


/**
 * 保護性暫停例項四
 *
 * @author 微信公眾號「JAVA前線」
 */
public class ProtectDesignTest4 {

    public static void main(String[] args) throws Exception {
        for (int i = 0; i < 3; i++) {
            final int index = i;
            new Thread(() -> {
                try {
                    MyNewData data = new MyNewData("hello_" + index);
                    MyResource resource = MyFutures.newResource(data);
                    // 模擬傳送耗時
                    TimeUnit.SECONDS.sleep(1);
                    resource.sendData(data);
                    System.out.println("生產資料data=" + data);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

            }).start();
        }
        TimeUnit.SECONDS.sleep(1);

        for (Long i : MyFutures.getIds()) {
            final long index = i;
            new Thread(() -> {
                MyResource resource = MyFutures.getResource(index);
                int timeOut = 3000;
                System.out.println("接收資料data=" + resource.getData(timeOut));
            }).start();
        }
    }
}

5 DUBBO應用例項

我們順著這一個鏈路跟蹤程式碼:消費者傳送請求 > 提供者接收請求並執行,並且將結果傳送給消費者 >消費者接收執行結果。

(1) 消費者傳送請求

消費者傳送的資料包含請求ID,並且將關係維護進FUTURES容器

final class HeaderExchangeChannel implements ExchangeChannel {

    @Override
    public ResponseFuture request(Object request, int timeout) throws RemotingException {
        if (closed) {
            throw new RemotingException(this.getLocalAddress(), null, "Failed to send request " + request + ", cause: The channel " + this + " is closed!");
        }
        Request req = new Request();
        req.setVersion(Version.getProtocolVersion());
        req.setTwoWay(true);
        req.setData(request);
        // 程式碼1
        DefaultFuture future = DefaultFuture.newFuture(channel, req, timeout);
        try {
            channel.send(req);
        } catch (RemotingException e) {
            future.cancel();
            throw e;
        }
        return future;
    }
}

class DefaultFuture implements ResponseFuture {

    // FUTURES容器
    private static final Map<Long, DefaultFuture> FUTURES = new ConcurrentHashMap<>();

    private DefaultFuture(Channel channel, Request request, int timeout) {
        this.channel = channel;
        this.request = request;
        // 請求ID
        this.id = request.getId();
        this.timeout = timeout > 0 ? timeout : channel.getUrl().getPositiveParameter(Constants.TIMEOUT_KEY, Constants.DEFAULT_TIMEOUT);
        FUTURES.put(id, this);
        CHANNELS.put(id, channel);
    }
}

(2) 提供者接收請求並執行,並且將結果傳送給消費者


public class HeaderExchangeHandler implements ChannelHandlerDelegate {

    void handleRequest(final ExchangeChannel channel, Request req) throws RemotingException {
        // response與請求ID對應
        Response res = new Response(req.getId(), req.getVersion());
        if (req.isBroken()) {
            Object data = req.getData();
            String msg;
            if (data == null) {
                msg = null;
            } else if (data instanceof Throwable) {
                msg = StringUtils.toString((Throwable) data);
            } else {
                msg = data.toString();
            }
            res.setErrorMessage("Fail to decode request due to: " + msg);
            res.setStatus(Response.BAD_REQUEST);
            channel.send(res);
            return;
        }
        // message = RpcInvocation包含方法名、引數名、引數值等
        Object msg = req.getData();
        try {

            // DubboProtocol.reply真正執行業務方法
            CompletableFuture<Object> future = handler.reply(channel, msg);

            // 如果請求已經完成則設定並返回結果
            if (future.isDone()) {
                res.setStatus(Response.OK);
                res.setResult(future.get());
                channel.send(res);
                return;
            }
        } catch (Throwable e) {
            res.setStatus(Response.SERVICE_ERROR);
            res.setErrorMessage(StringUtils.toString(e));
            channel.send(res);
        }
    }
}

(3) 消費者接收結果

以下DUBBO原始碼很好體現了保護性暫停這個設計模式,說明參看註釋

class DefaultFuture implements ResponseFuture {
    private final Lock lock = new ReentrantLock();
    private final Condition done = lock.newCondition();

    public static void received(Channel channel, Response response) {
        try {
            // 取出對應的請求物件
            DefaultFuture future = FUTURES.remove(response.getId());
            if (future != null) {
                future.doReceived(response);
            } else {
                logger.warn("The timeout response finally returned at "
                            + (new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS").format(new Date()))
                            + ", response " + response
                            + (channel == null ? "" : ", channel: " + channel.getLocalAddress()
                               + " -> " + channel.getRemoteAddress()));
            }
        } finally {
            CHANNELS.remove(response.getId());
        }
    }


    @Override
    public Object get(int timeout) throws RemotingException {
        if (timeout <= 0) {
            timeout = Constants.DEFAULT_TIMEOUT;
        }
        if (!isDone()) {
            long start = System.currentTimeMillis();
            lock.lock();
            try {
                while (!isDone()) {

                    // 放棄鎖並使當前執行緒阻塞,直到發出訊號中斷它或者達到超時時間
                    done.await(timeout, TimeUnit.MILLISECONDS);

                    // 阻塞結束後再判斷是否完成
                    if (isDone()) {
                        break;
                    }

                    // 阻塞結束後判斷是否超時
                    if(System.currentTimeMillis() - start > timeout) {
                        break;
                    }
                }
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            } finally {
                lock.unlock();
            }
            // response物件仍然為空則丟擲超時異常
            if (!isDone()) {
                throw new TimeoutException(sent > 0, channel, getTimeoutMessage(false));
            }
        }
        return returnFromResponse();
    }

    private void doReceived(Response res) {
        lock.lock();
        try {
            // 接收到伺服器響應賦值response
            response = res;
            if (done != null) {
                // 喚醒get方法中處於等待的程式碼塊
                done.signal();
            }
        } finally {
            lock.unlock();
        }
        if (callback != null) {
            invokeCallback(callback);
        }
    }
}

6 文章總結

本文我們從基礎案例介紹保護性暫停基本概念和實踐,最終分析DUBBO原始碼中保護性暫停設計模式使用場景。我們在設計併發框架時要注意虛假喚醒問題,以及請求和響應關係對應問題,希望文章對大家有所幫助。

歡迎關注公眾號【JAVA前線】檢視精彩文章


在這裡插入圖片描述

相關文章