博客 / 詳情

返回

Go語言有沒有結構化編程?

本文原文地址在本博主博客,點擊鏈接前往:Go語言中有沒有結構化併發?

什麼是結構化併發?日常開發中我們編寫的最多就是多線程程序,服務器端應用更是如此,傳統的方式都是依靠着操作系統提供的1:1線程方式進行請求處理這對於管理和複用線程有很多挑戰,如果一個普通線程大小2MB那麼開啓1000個線程,幾乎是無法完成的,並且管理這些線程的狀態也是很複雜的。今天這篇文章要介紹的是結構化併發,就是為解決併發編程中線程併發任務管理,傳統的方式非常容易造成管理混亂。結構化併發解決的問題就是對統一的任務和統一作用域下的任務進行管理,可以統一啓動和統一關閉,如果讀過我之前的Linux進程組那篇文章的話,就完全可以理解是什麼意思了,文章地址:Linux 進程樹。

在瞭解結構併發編程範式之前得先講講編程語言流程控制發展史,瞭解一件事的全部應該是去了解完整的歷史,並且要找到正確的資料和原版資料去了解,而不是已經修改幾個版本的資料,讓我們回顧編程語言的一些歷史:早期如果想在計算機上寫程序必須使用很低級的編程語言去寫程序,例如彙編語言,通過一條一條硬件指令去操作計算機,並且順序執行的,這種編寫程序的方式真是令人頭疼的。這就使一些計算機界大佬想去重新設計一些編程語言,當時一些美籍計算機科學家們John Warner Backus和Grace Hopper開發了Fortran和FLOW-MATIC初代的編譯命令式編程語言,最後在這些基礎之上開發了商業通用編程COBOL語言。

有趣的事情是世界上的第一個Bug也是Grace Hopper所發現的,當時的計算機(Harvard Mark II)體積還很大。當時這台計算機在運算的時候老是出現問題,但是經過排查編寫的程序指令是沒有問題的,最後發現原來是一隻飛蛾意外飛入電腦內部的繼電器而造成短路如下圖所示,他們把這隻飛蛾移除後便成功讓電腦正常運作,這就是世界上第一個計算機程序BUG。

早期的FLOW-MATIC是第一種使用類似英語的語句來表達操作的編程語言,會預先定義輸入和輸出文件和打印輸出,分為輸入文件、輸出文件和高速打印機輸出,下面是一段程序代碼的例子:

看完上面的實例,會發現和現在開發者所使用的更高級的Java或者C語言還是有一些差距的,例如沒有函數代碼塊,沒有條件控制語句,在FLOW-MATIC被推出的時候這些現在高級語言的特性還沒有被髮明出來,在當時看來FLOW-MATIC應該是能滿足編寫程序需求。

設想一下如果和輸入指令一條一條執行程序是不是很麻煩,如果不能複用一些以有編寫邏輯那就要重新編寫一些代碼邏輯會很費時費力,所以FLOW-MATIC的設計者在語言加入了GOTO語句塊,goto可以讓程序在執行的時候執行到了goto然後去執行指定位置的代碼塊,本質上還是非結構化編程,不過可以做到程序的代碼複用和重執行,goto的加入FLOW-MATIC之後如下程序執行流程圖:

FLOW-MATIC執行語句通常都是順序執行的,但是下面這種情況就會發生跳轉操作,它可以直接將控制權轉移到其他地方,例如下面從8行跳轉到第4行。

極少量的goto語句是很清晰的,但是令人頭疼的問題是程序代碼邏輯量變多了之後就會產生很多無法通過正常人類思維所理解的代碼跳轉邏輯,並且跟蹤代碼的邏輯很困難。這種基於跳轉的編程風格是FLOW-MATIC幾乎直接從彙編語言繼承而來的。它功能強大,非常適合計算機硬件的實際工作方式,但直接使用它會非常混亂。像上面圖片中的箭頭箭頭太多了,就發明Spaghetti Code一詞的原因,代碼邏輯存在各種飛線關係,揉成一坨的代碼邏輯。顯然我們開發者需要更好的流程控制設計,而不是讓代碼邏輯寫出來像意大利麪條一樣。

當然目前討論的話題是編程語言的結構化編程設計問題,這個不是本篇文章的重點,本篇文章更偏向的是一些編程語言在線程併發狀態轉播和控制管理上的一些問題,下面正式開始正文內容。

非結構化併發

介紹了早期編程語言中的goto關鍵字,可以在當前的執行控制流中開一個分支去執行另外的操作,和我們現在在高級編程語言中使用的thread差不多,例如下面代碼:

package main

import (
    "fmt"
    "time"
)

func f(from string) {
    for i := 0; i < 3; i++ {
        go fmt.Println(from, ":", i)
    }
}

func main() {

    f("direct")

    go f("goroutine")

    go func(msg string) {
        fmt.Println(msg)
    }("going")

    time.Sleep(2 * time.Second)
    fmt.Println("done")
}

在線運行代碼地址: https://go.dev/play/p/wQ7Yz9mxXlu

在這個例子中我使用的是Go語言的goroutine為例,在Go語言中想啓動一個協程就可以使用go關鍵字,這和上面我們討論的goto語句很接近,會從主控制流中分離出另一個代碼邏輯執行分支,流程如下圖:

當然在Go語言中是保留goto跳轉語句塊的,例如下面這行代碼就是Go中的goto語句塊:

package main

import "fmt"

func main() {
   /* 定義局部變量 */
   var a int = 10

   /* 循環 */
   LOOP: for a < 20 {
      if a == 15 {
         /* 跳過迭代 */
         a = a + 1
         goto LOOP
      }
      fmt.Printf("a的值為 : %d\n", a)
      a++    
   }  
}

在這個例子中goto代替了傳統的break關鍵字的作用(那個例子準確來説應該是説類似於continue作用,看怎麼用了,這裏不接受任何反駁!),直接跳過滿足a==15的邏輯塊。這就是目前高級語言中的跳轉應用,當前這種還是在主程序流上運行的指令的,於Go語言中的go func(){}關鍵字去跑起一個協程做並行任務處理是完全不一樣的,為此我特定花了一張圖來比較兩者的關係,如下:

像上面這樣的通過go關鍵字啓動的協程就和一個不透明的盒子一樣,你不知道被啓動代碼塊裏面是否還有go關鍵字啓動其他協程,遞歸啓動協程是一件很難控制的事件,這就和mapreduce思想很像,最終還是要彙總的每個協程中產生的數據和控制協程狀態的,如下圖:

像上面這幅圖中如果裏面的每個圓圈⭕️都代表着一個正在並行處理任務的協程,我們要如何管理這些協程狀態呢?當然Go語言在設計的時候就引入了channel概念,我們開發者可以顯示將channel提供代碼的方式嵌入到每個要執行協程任務代碼塊中;早期的Go版本中為了控制協程中的協程狀態是直接嵌入channel然後再每個協程內部編寫具體狀態控制代碼,如果上級發送了通知那麼此協程會做出相應的動作,這是初步的Go版本狀態控制。

在最新Go語言設計的版本中為了管理這些協程,在語言默認標準庫中通過了context包所提供功能來做並行協程上下文通訊和狀態同步:

package main

import (
    "context"
    "fmt"
    "time"
)

func doSomething(ctx context.Context) {
    ctx, cancelCtx := context.WithCancel(ctx)
    
    printCh := make(chan int)
    go doAnother(ctx, printCh)

    for num := 1; num <= 3; num++ {
        printCh <- num
    }

    cancelCtx()

    time.Sleep(100 * time.Millisecond)

    fmt.Printf("doSomething: finished\n")
}

func doAnother(ctx context.Context, printCh <-chan int) {
    for {
        select {
        case <-ctx.Done():
            if err := ctx.Err(); err != nil {
                fmt.Printf("doAnother err: %s\n", err)
            }
            fmt.Printf("doAnother: finished\n")
            return
        case num := <-printCh:
            fmt.Printf("doAnother: %d\n", num)
        }
    }
}

本示例代碼在線地址:How To Use Contexts in Go

關於結構化併發在Go語言中一些問題上面是我個人見解,還有一些關於Go中的結構化併發討論的文章可以查看這篇文章:Go statement considered harmful,在這篇文章裏面作者對現有的Go語言協程設計拋出很多觀點值得一讀。

結構化併發設計

在上面我介紹了一些關於非結構化併發的程序設計問題,如果單獨創建協程沒有做好錯誤處理或者異常情況下的處理,可能就會出現協程泄露問題,這就是本節要講的結構化併發來做的併發控制設計。

其結構化併發核心設計原則是: <span style="color:red;">可以通過代碼明確併發程序任務集的入口點和出口點,並確保所有衍生線程在退出之前完成的控制流構造來封裝併發執行線程,能將子線程發生的錯誤傳播到控制結構到父範圍上,並且達到無子線程存在泄漏問題。</span>

這裏我會拿我目前還稍微熟悉一點的Java語言舉例,例如在Java19中添加的結構體併發特性,所採用的線程控制就是結構化併發的應用,如下的示例代碼:

void serve(ServerSocket serverSocket) throws IOException, InterruptedException {
    try (var scope = new StructuredTaskScope<Void>()) {
        try {
            while (true) {
                var socket = serverSocket.accept();
                scope.fork(() -> handle(socket));
            }
        } finally {
            // If there's been an error or we're interrupted, we stop accepting
            scope.shutdown();  // Close all active connections
            scope.join();
        }
    }
}

上面代碼的邏輯就是一個簡單的socket處理邏輯,採用的就是結構化併發,可以看到finally裏面的異常處理邏輯和scope任務線程塊,當然這些內容在Oracle公司的Open JDK設計草案裏面就有地址如下:https://openjdk.org/jeps/428,我只是對這篇內容做了導讀和個人見解分享,當然這裏我拿幾個語言作為例子不是為了討論誰好誰壞,而是從語言設計角度來看每個不同語言面對這些問題是怎麼解決的,瑕瑜互見。

小結

我個人認為結構化併發是未來的併發和並行程序設計方向,現在有結構化併發程序設計的語言Kotlin、Java、Swift等,Rust語言中也有這方面相關第三方實現目前還不夠完善。由此可見通過作用域定義了主協程的子協程的生命週期和關係,事實證明,這一原則在協程中實施了層次結構。如果協程需要為自己創建子協程,那完全沒問題,就像您如何將if語句嵌套在一起並理解分支如何嵌套一樣,協程也可以嵌套,最頂級的協程不僅取決於他們的孩子完成,還取決於他們孩子的孩子,這就是一個多叉樹型的結構,更多相關研究還要靠着開發者們一起探索,我下面給出一些這方面領先的技術文章分享鏈接。

其他資料

  • Go statement considered harmful
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.