閱讀545 返回首頁    go 技術社區[雲棲]


軟件事務內存導論(十一)-STM的局限性

1.1    STM的局限性

STM消除了顯式的同步操作,所以我們在寫代碼時就無需擔心自己是否忘了進行同步或是否在錯誤的層級上進行了同步。然而STM本身也存在一些問題, 比如在跨越內存柵欄失敗或遭遇競爭條件時我們捕獲不到任何有用的信息。我似乎可以聽到你內心深處那個精明的程序員在抱怨“怎麼會這樣啊?”。確實,STM 是有其局限性的,否則本書寫到這裏就應該結束了。STM隻適用於寫衝突非常少的應用場景,如果你的應用程序存在很多寫操作競爭,那麼我們就需要在STM之 外尋找解決方案了。

下麵讓我們進一步討論STM的局限性。STM提供了一種顯式的鎖無關編程模型,這種模型允許多個事務並發地運行,並且在沒有發生衝突時所有事務都能 毫無滯礙地運行,所以相對其他編程模型而言STM可以提供更好的並發性和線程安全方麵的保障。當事務對相同對象或數據的寫訪問發生衝突時,隻有一個事務能 夠順利完成,其他事務都會被自動重做。這種重做機製延緩了寫操作衝突時競爭失敗的那些寫者的執行,但卻提升了讀者和競爭操作的勝利者的執行速度。當對於相 同對象的並發寫操作不頻繁時,其性能就不會受到太大影響。但是隨著衝突的增多,程序整體性能將因此變得越來越差。

如果對相同數據有很高的寫衝突概率,那麼我們的應用程序輕則寫操作變慢,重則會因為重試太多次而導致失敗。目前在本章我們所看到的例子都是在展示STM的優勢,但是在下麵的例子中我們將會看到,雖然STM是易於使用的,但也並非在所有應用場景下都能得到理想的結果。

在4.2節的示例中,當多個線程同時訪問多個目錄時,我們使用AtomicLong來對文件大小的並發更新操作進行同步。此外,如果需要同時更新多 個變量,我們也必須依賴同步才能完成。雖然表麵看起來使用STM對這段代碼進行重構似乎是個不錯的選擇,但大量的寫衝突卻使得STM不適用於這個應用場 景。下麵就讓我們將上述計算目錄大小的程序改用STM實現,並觀察其運行結果是否如我們所預料的那麼差。

在下麵的代碼中,我們沒有使用AtomicLong,而是采用了Akka托管引用作為FileSizeWSTM的屬性字段。

1 public class FileSizeWSTM {
2 private ExecutorService service;
3 final private Ref<Long> pendingFileVisits = new Ref<Long>(0L);
4 final private Ref<Long> totalSize = new Ref<Long>(0L);
5 final private CountDownLatch latch = new CountDownLatch(1);

為了保證安全性,pendingFileVisits的增減都需要在事務內完成。而在之前使用AtomicLong時,我們隻需要簡單調用 incrementAndGet()函數和decrementAndGet()函數就行了。但是由於托管引用都是通用的(generic),沒有專門針對 數字類型的處理方法,所以我們還需要針對pendingFileVisits進行一些額外的加工,即把對於pendingFileVisits的操作封裝 到一個單獨的函數裏。

1 private long updatePendingFileVisits(final int value) {
2 return new Atomic<Long>() {
3 public Long atomically() {
4 pendingFileVisits.swap(pendingFileVisits.get() + value);
5 return pendingFileVisits.get();
6 }
7 }.execute();
8 }

在完成上述定義之後,訪問目錄和計算文件大小的函數就相對容易多了,我們隻需要把程序中的AtomicLong替換成托管引用就好。

01 private void findTotalSizeOfFilesInDir(final File file) {
02 try {
03 if (!file.isDirectory()) {
04 new Atomic() {
05 public Object atomically() {
06 totalSize.swap(totalSize.get() + file.length());
07 return null;
08 }
09 }.execute();
10 } else {
11 final File[] children = file.listFiles();
12 if (children != null) {
13 for(final File child : children) {
14 Limitations of STM • 137
15 updatePendingFileVisits(1);
16 service.execute(new Runnable() {
17 public void run() {
18 findTotalSizeOfFilesInDir(child); }
19 });
20 }
21 }
22 }
23 if(updatePendingFileVisits(-1) == 0) latch.countDown();
24 } catch(Exception ex) {
25 System.out.println(ex.getMessage());
26 System.exit(1);
27 }
28 }

最後,我們還需要寫一些創建executor服務池和使程序運行起來的代碼:

01 private long getTotalSizeOfFile(final String fileName)
02 throws InterruptedException {
03 service = Executors.newFixedThreadPool(100);
04 updatePendingFileVisits(1);
05 try {
06 findTotalSizeOfFilesInDir(new File(fileName));
07 latch.await(100, TimeUnit.SECONDS);
08 return totalSize.get();
09 } finally {
10 service.shutdown();
11 }
12 }
13 public static void main(final String[] args) throws InterruptedException {
14 final long start = System.nanoTime();
15 final long total = new FileSizeWSTM().getTotalSizeOfFile(args[0]);
16 final long end = System.nanoTime();
17 System.out.println("Total Size: " + total);
18 System.out.println("Time taken: " + (end - start)/1.0e9);
19 }
20 }

由於我懷疑這段代碼跑起來之後可能有問題,所以如果在程序中抓到事務失敗所導致的異常,我就會結束掉整個應用程序。

根據事務的定義,如果變量的值在事務提交之前發生了改變,那麼事務將會自動重做。在本例中,多個線程會同時競爭修改這兩個可變變量,從而導致程序運 行變慢或失敗。我們可以在多個不同的目錄上分別運行上述示例代碼來進行觀察,下麵就列出了該示例程序在我的電腦上計算/etc和/usr這兩個目錄的輸出 結果:

1 Total file size for /etc
2 Total Size: 2266408
3 Time taken: 0.537082
4 Total file size for /usr
5 Too many retries on transaction 'DefaultTransaction', maxRetries = 1000
6 Too many retries on transaction 'DefaultTransaction', maxRetries = 1000
7 Too many retries on transaction 'DefaultTransaction', maxRetries = 1000
8 ...

從輸出結果來看,STM版本對於/etc目錄的計算結果與之前使用AtomicLong的那個版本是完全相同的。但是由於會產生過多的重試操作,所 以STM版本的運行時間要比後者慢一個數量級。而遍曆/usr目錄的運行情況則更為糟糕,有相當多的事務超過了默認的最大重試限製。雖然我們的邏輯是一抓 到異常就會立即終止整個程序,但由於多個事務是並發運行的,所以在程序真正停止之前我們還是能看到多條錯誤信息輸出到控製台。

有個別評論家曾建議說是否用commute代替alter會對解決這個問題有所幫助。請回憶我們在6.4節中曾討論過的在Clojure中用來修改 托管引用的那三個函數。由於在事務失敗之後不會進行重試,所以commute可以提供比alter更高的並發度。此外,commute也不會在沒有 hold住調用方事務的情況下就單獨執行提交操作。然而單純就計算目錄大小這個程序而言,使用commute對性能的提升十分有限。在麵對結構複雜的大型 目錄時,使用該函數也無法在提供良好性能的前提下獲得一致性的結果。除了將alter換成commute之外,我們還可以嚐試將atom與swap!函數 一起使用。雖然atom是不可調整並且同步的操作,但其優點是不需要使用事務。此外,atom僅能在對單個變量(例如計算目錄大小示例中用於記錄目錄大小 的變量)的變更時使用,並且變更期間不會遇到任何事務性重試。然而,由於在對atom做變更時會產生對用戶透明的同步操作,所以我們依然會遇到同步操作所 導致的延遲問題。

由於大量線程會同時嚐試更新totalSize變量,所以計算目錄大小示例在實際執行過程中會產生非常頻繁的寫衝突,這也就意味著STM不適合於解 決此問題。事實上,當讀操作十分頻繁且寫衝突被控製在合理範圍內時,STM的性能還是不錯的,同時還能幫程序員免除顯式同步的負擔。但是在不考慮一般程序 中常見的其他導致延時問題的前提下,如果待解決問題中含有大量寫衝突,那就請不要使用STM,而是考慮采用我們在第8章中將會討論的actor模型來避免 同步操作。

1.1    小結

STM是一個針對並發問題的非常強大的編程模型,該模型有很多優點:

  • STM可以根據應用程序的行為來充分挖掘出其最大的並發潛力。也就是說,用了STM之後,我們可以無需使用過度保守的、需要預先定義的同步操作,而是讓STM動態地管理競爭衝突。
  • STM是一種鎖無關的編程模型,該模型可以提供良好的線程安全性和很高的並發性能。
  • STM可以保證實體僅能在事務內被更改。
  • STM沒有顯式鎖意味著我們從此無需擔心加鎖順序及其他相關問題。
  • STM可以幫助我們減輕前期設計的決策負擔,有了它我們就無需關心誰對什麼東西上了鎖,而隻需放心地把這些工作交給動態隱式組合鎖(implicit lock composition)。

該模型適用於對相同數據存在並發讀且寫衝突不頻繁的應用場景。

如果應用程序的數據訪問方式符合STM的適用範疇,則STM就為我們提供了一種處理共享可變性的高效解決方案。而如果我們的應用場景裏寫衝突非常 多,我們可能就會更傾向於使用將在第8章中討論的基於角色(actor)的模型。但在下一章,還是讓我們先學習一下如何在其他JVM上的語言中使用STM 編程模型。


文章轉自 並發編程網-ifeve.com

最後更新:2017-05-22 16:01:33

  上一篇:go  讓科學的“可重複性流程”重回數據科學
  下一篇:go  5招教你用Python構建好玩的深度學習應用