使用sync.Once實現高效的單例模式

菜鳥額發表於2023-03-16

1. 簡介

本文介紹使用sync.Once來實現單例模式,包括單例模式的定義,以及使用sync.Once實現單例模式的示例,同時也比較了其他單例模式的實現。最後以一個開源框架中使用sync.Once實現單例模式的例子來作為結尾。

2. 基本實現

2.1 單例模式定義

單例模式是一種建立型設計模式,它保證一個類只有一個例項,並提供一個全域性訪問點來訪問這個例項。在整個應用程式中,所有對於這個類的訪問都將返回同一個例項物件。

2.2 sync.Once實現單例模式

下面是一個簡單的示例程式碼,使用 sync.Once 實現單例模式:

 package singleton

 import "sync"

 type singleton struct {
     // 單例物件的狀態
 }

 var (
     instance *singleton
     once     sync.Once
 )

 func GetInstance() *singleton {
     once.Do(func() {
         instance = &singleton{}
         // 初始化單例物件的狀態
     })
     return instance
 }

在上面的示例程式碼中,我們定義了一個 singleton 結構體表示單例物件的狀態,然後將它的例項作為一個包級別的變數 instance,並使用一個 once 變數來保證 GetInstance 函式只被執行一次。

GetInstance 函式中,我們使用 once.Do 方法來執行一個初始化單例物件。由於 once.Do 方法是基於原子操作實現的,因此可以保證併發安全,即使有多個協程同時呼叫 GetInstance 函式,最終也只會建立一個物件。

2.3 其他方式實現單例模式

2.3.1 全域性變數定義時賦值,實現單例模式

在 Go 語言中,全域性變數會在程式啟動時自動初始化。因此,如果在定義全域性變數時給它賦值,則物件的建立也會在程式啟動時完成,可以透過此來實現單例模式,以下是一個示例程式碼:

type MySingleton struct {
    // 欄位定義
}

var mySingletonInstance = &MySingleton{
    // 初始化欄位
}

func GetMySingletonInstance() *MySingleton {
    return mySingletonInstance
}

在上面的程式碼中,我們定義了一個全域性變數 mySingletonInstance 並在定義時進行了賦值,從而在程式啟動時完成了物件的建立和初始化。在 GetMySingletonInstance 函式中,我們可以直接返回全域性變數 mySingletonInstance,從而實現單例模式。

2.3.2 init 函式實現單例模式

在 Go 語言中,我們可以使用 init 函式來實現單例模式。init 函式是在包被載入時自動執行的函式,因此我們可以在其中建立並初始化單例物件,從而保證在程式啟動時就完成物件的建立。以下是一個示例程式碼:

package main

type MySingleton struct {
    // 欄位定義
}

var mySingletonInstance *MySingleton

func init() {
    mySingletonInstance = &MySingleton{
        // 初始化欄位
    }
}

func GetMySingletonInstance() *MySingleton {
    return mySingletonInstance
}

在上面的程式碼中,我們定義了一個包級別的全域性變數 mySingletonInstance,並在 init 函式中建立並初始化了該物件。在 GetMySingletonInstance 函式中,我們直接返回該全域性變數,從而實現單例模式。

2.3.3 使用互斥鎖實現單例模式

在 Go 語言中,可以只使用一個互斥鎖來實現單例模式。下面是一個簡單程式碼的演示:

var instance *MySingleton
var mu sync.Mutex

func GetMySingletonInstance() *MySingleton {
   mu.Lock()
   defer mu.Unlock()

   if instance == nil {
      instance = &MySingleton{
         // 初始化欄位
      }
   }
   return instance
}

在上面的程式碼中,我們使用了一個全域性變數instance來儲存單例物件,並使用了一個互斥鎖 mu 來保證物件的建立和初始化。具體地,我們在 GetMySingletonInstance 函式中首先加鎖,然後判斷 instance 是否已經被建立,如果未被建立,則建立並初始化物件。最後,我們釋放鎖並返回單例物件。

需要注意的是,在併發高的情況下,使用一個互斥鎖來實現單例模式可能會導致效能問題。因為在一個 goroutine 獲得鎖並建立物件時,其他的 goroutine 都需要等待,這可能會導致程式變慢。

2.4 使用sync.Once實現單例模式的優點

相對於init 方法和使用全域性變數定義賦值單例模式的實現,sync.Once 實現單例模式可以實現延遲初始化,即在第一次使用單例物件時才進行建立和初始化。這可以避免在程式啟動時就進行物件的建立和初始化,以及可能造成的資源的浪費。

而相對於使用互斥鎖實現單例模式,使用 sync.Once 實現單例模式的優點在於更為簡單和高效。sync.Once提供了一個簡單的介面,只需要傳遞一個初始化函式即可。相比互斥鎖實現方式需要手動處理鎖、判斷等操作,使用起來更加方便。而且使用互斥鎖實現單例模式需要在每次訪問單例物件時進行加鎖和解鎖操作,這會增加額外的開銷。而使用 sync.Once 實現單例模式則可以避免這些開銷,只需要在第一次訪問單例物件時進行一次初始化操作即可。

但是也不是說sync.Once便適合所有的場景,這個是需要具體情況具體分析的。下面說明sync.Onceinit方法,在哪些場景下使用init更好,在哪些場景下使用sync.Once更好。

2.5 sync.Once和init方法適用場景

對於init實現單例,比較適用於在程式啟動時就需要初始化變數的場景。因為init函式是在程式執行前執行的,可以確保變數在程式執行時已經被初始化。

對於需要延遲初始化某些物件,物件被建立出來並不會被馬上使用,或者可能用不到,例如建立資料庫連線池等。這時候使用sync.Once就非常合適。它可以保證物件只被初始化一次,並且在需要使用時才會被建立,避免不必要的資源浪費。

3. gin中單例模式的使用

3.1 背景

這裡首先需要介紹下gin.Engine, gin.Engine是Gin框架的核心元件,負責處理HTTP請求,路由請求到對應的處理器,處理器可以是中介軟體、控制器或處理HTTP響應等。每個gin.Engine例項都擁有自己的路由表、中介軟體棧和其他配置項,透過呼叫其方法可以註冊路由、中介軟體、處理函式等。

一個HTTP伺服器,只會存在一個對應的gin.Engine例項,其儲存了路由對映規則等內容。

為了簡化開發者Gin框架的使用,不需要使用者建立gin.Engine例項,便能夠完成路由的註冊等操作,提高程式碼的可讀性和可維護性,避免重複程式碼的出現。這裡對於一些常用的功能,抽取出一些函式來使用,函式簽名如下:

// ginS/gins.go
// 載入HTML模版檔案
func LoadHTMLGlob(pattern string) {}
// 註冊POST請求處理器
func POST(relativePath string, handlers ...gin.HandlerFunc) gin.IRoutes {}
// 註冊GET請求處理器
func GET(relativePath string, handlers ...gin.HandlerFunc) gin.IRoutes {}
// 啟動一個HTTP伺服器
func Run(addr ...string) (err error) {}
// 等等...

接下來需要對這些函式來進行實現。

3.2 具體實現

首先從使用出發,這裡使用POST方法/GET方法註冊請求處理器,然後使用Run方法啟動伺服器:

func main() {
   // 註冊url對應的處理器
   POST("/login", func(c *gin.Context) {})
   // 註冊url對應的處理器
   GET("/hello", func(c *gin.Context) {})
   // 啟動服務
   Run(":8080")
}

這裡我們想要的效果,應該是呼叫Run方法啟動服務後,往/login路徑傳送請求,此時應該執行我們註冊的對應處理器,往/hello路徑傳送請求也是同理。

所以,這裡POST方法,GET方法,Run方法應該都是對同一個gin.Engine 進行操作的,而不是各自使用各自的gin.Engine例項,亦或者每次呼叫就建立一個gin.Engine例項。這樣子才能達到我們預想的效果。

所以,我們需要實現一個方法,獲取gin.Engine例項,每次呼叫該方法都是獲取到同一個例項,這個其實也就是單例的定義。然後POST方法,GET方法又或者是Run方法,呼叫該方法獲取到gin.Engine例項,然後呼叫例項去呼叫對應的方法,完成url處理器的註冊或者是服務的啟動。這樣子就能夠保證是使用同一個gin.Engine例項了。具體實現如下:

// ginS/gins.go
import (
   "github.com/gin-gonic/gin"
)
var once sync.Once
var internalEngine *gin.Engine

func engine() *gin.Engine {
   once.Do(func() {
      internalEngine = gin.Default()
   })
   return internalEngine
}
// POST is a shortcut for router.Handle("POST", path, handle)
func POST(relativePath string, handlers ...gin.HandlerFunc) gin.IRoutes {
   return engine().POST(relativePath, handlers...)
}

// GET is a shortcut for router.Handle("GET", path, handle)
func GET(relativePath string, handlers ...gin.HandlerFunc) gin.IRoutes {
   return engine().GET(relativePath, handlers...)
}

這裡engine() 方法使用了 sync.Once 實現單例模式,確保每次呼叫該方法返回的都是同一個 gin.Engine 例項。然後POST/GET/Run方法透過該方法獲取到gin.Engine例項,然後呼叫例項中對應的方法來完成對應的功能,從而達到POST/GET/Run等方法都是使用同一個例項操作的效果。

3.3 sync.Once實現單例的好處

這裡想要達到的目的,其實是GET/POST/Run等抽取出來的函式,使用同一個gin.Engine例項。

為了達到這個目的,我們其實可以在定義internalEngine 變數時,便對其進行賦值;或者是通init函式完成對internalEngine變數的賦值,其實都可以。

但是我們抽取出來的函式,使用者並不一定使用,定義時便初始化或者在init方法中便完成了對變數的賦值,使用者沒使用的話,建立出來的gin.Engine例項沒有實際用途,造成了不必要的資源的浪費。

而engine方法使用sync.Once實現了internalEngin的延遲初始化,只有在真正使用到internalEngine時,才會對其進行初始化,避免了不必要的資源的浪費。

這裡其實也印證了上面我們所說的sync.Once的適用場景,對於不會馬上使用的單例物件,此時可以使用sync.Once來實現。

4.總結

單例模式是一種常用的設計模式,用於保證一個類僅有一個例項。在單例模式中,常常使用互斥鎖或者變數賦值的方式來實現單例。然而,使用sync.Once可以更方便地實現單例,同時也能夠避免了不必要的資源浪費。當然,沒有任何一種實現是適合所有場景的,我們需要根據具體場景具體分析。

相關文章