利⽤⽆鎖隊列實現的協程池,簡約⽽不簡單

前言

衆所周知,golang協程的創建、銷燬、調度是非常輕量的,但是即便再輕量,規模大了開銷也不能忽視的。比如利用協程處理http請求,每個請求用一個協程處理,當QPS上萬的時候,資源消耗還是比較大的。

協程池和線程池一樣,池子中都是熱協程,需要的時候取出來,用完後歸還,避免了高頻率的創建和銷燬。同時協程池還能將空閒超時的協程銷燬來釋放資源,並且還有一定保護能力,通過設定協程最大數量避免無休止的創建協程把系統資源消耗殆盡。

總之,golang雖然提供了非常輕量且容易使用的協程編程環境,但是不同的應用場景對於協程的使用需求也是不同的,協程池就是一種非常通用的應用場景。

無鎖隊列

在介紹協程池的實現之前需要簡單說明一下無鎖隊列,關於無鎖隊列的實現網上有很多文章,此處只簡單的說一些根本文實現有關的重點內容:CAS操作——Compare & Set,或是 Compare & Swap,現在幾乎所有的CPU指令都支持CAS的原子操作,X86下對應的是 CMPXCHG 彙編指令。有了這個原子操作,就可以用其來實現各種無鎖(lock free)的數據結構,本文使用的就是atomic.CompareAndSwapPointer。

實現

本文實現的源碼開源連接爲:https://github.com/jinde-zgm/gopool.git,接下來進入CAAD(代碼即文檔)模式,通篇只有代碼和註釋!

接口

// Pool定義了協程池的接口
type Pool interface {
    Name() string             // 獲取協程池的名字,當有多個協程池對象的時候可以用名字區分不同的協程池
    Capacity() int32          // 獲取協程池的容量,即最大協程數量
    Tune(size int32)          // 修改協程池的容量
    Status() Status           // 獲取協程池的狀態,關於狀態下面有定義
    Go(Routine) error         // 執行(阻塞)Routine,關於Routine下面也有定義
    GoNonblock(Routine) error // 非阻塞執行Routine,當協程數量達到最大值且無空閒協程時立刻返回
    Close()                   // 關閉協程池
}

// Routine定義了協程池執行的函數,context避免協程池關閉的時候協程被阻塞,也就是說協程池的使用者
// 需要將函數實現成Routine形式才能被協程池調用,
type Routine func(context.Context)

// Status定義了協程池的狀態
type Status struct {
    Runnings int32 // 運行中的協程數量
    Idles    int32 // 空閒的協程數量
}

協程

// coroutine 定義了協程
type coroutine struct {
    rc     chan Routine  // Routine的chan,Pool.Go(Routine)通過rc傳遞給協程執行
    pool   *pool         // 協程池指針,每個協程通過pool指向協程池(pool是Pool的實現)
    active time.Time     // 活躍時間,最後一次執行完Routine的時間,用於清理空閒超時的協程
    next   *coroutine    // 下一個協程,所謂無鎖隊列就是用這個變量將協程形成了隊列
}

// run是協程的運行函數
func (c *coroutine) run() {
    // 此處只需要知道pool有一個sync.WaitGroup的成員變量wg,用來等待所有協程退出,
    // 所以協程退出的時候需要調動Done
    defer c.pool.wg.Done()

    // 前面提到了,通過chan Routine獲取函數
    for r := range c.rc {
        // 空指針表示協程需要退出,比如協程池關閉或者協程空閒超時都會收到nil
        if r == nil {
            return
        }

        // 執行Routine,此處傳入了協程池的context,建議Routine的實現select該context
        r(c.pool.ctx)

        // 執行完函數,將該協程放到協程池的空閒隊列,此處開始進入本文的核心內容了
        c.pool.pushIdle(c)
    }
}

無鎖隊列

// pushIdle把協程放入空閒協程隊列
func (p *pool) pushIdle(c *coroutine) {
    // 此時協程已經執行完Routine,需要記錄一下最後的活躍時間
    c.active = time.Now()
    for {
        // 獲取空閒隊列的第一個協程,即隊列頭,clean表示協程池是否正在清理空閒隊列
        head, clean := p.idleHead()
        if clean {
            // 如果協程池正在清理空閒協程,需要等清理完畢後再把協程放入到空閒隊列中,
            // 如何才能知道協程池清理完了呢?chan或者sync.Cond應該是比較容易想到的方案,
            // 筆者採用了自旋的方案,因爲清理空閒協程非常快且不頻繁,自旋是性能最好的方法。
            // 此處使用了runtime.Gosched()實現自旋,此時立查詢清理是否完成多半還是在清理中,
            // 倒不如把時間片讓出來給其他協程,實在沒事幹了再去查詢清理狀態會更有效的利用CPU。
            // runtime.Gosched()會讓協程釋放CPU時間片,筆者此處問一個問題,如果不調用該函數,
            // 採用死循環的方式自旋查詢清理狀態(即把runtime.Gosched()註釋掉)是否可行,
            // 答案是不行的,原因讀者應該能夠想明白。
            runtime.Gosched()
            continue
        }

        // 到這裏說明協程池不在清理狀態,c.storeNext(head)是用c.next->head(當前),
        // p.casIdleHead(head, unsafe.Pointer(c))利用CAS操作實現p.idles->c,
        // 相當於把c放入了隊列頭,c.next指向了以前的隊列頭。因爲CAS是原子操作,無需用鎖互斥
        // 就可以把協程放入隊列,這也是無鎖隊列的由來
        if c.storeNext(head); p.casIdleHead(head, unsafe.Pointer(c)) {
            // 運行中的協程數量-1,通過原子操作計數,因爲執行上面是多個協程併發執行的
            // 此處需要注意,在清理超時協程的時候會插入cleaning協程,不能計爲運行中的協程
            if c != cleaning {
                atomic.AddInt64(&p.count, int64(-1)<<32)
            }
            break
        }
    }
}

// casIdleHead利用CAS實現協程池頭指針的操作,casIdleHead不僅可以實現插入協程到隊列頭,
// 同時可以將隊列頭協程彈出,詳情見下面的popIdle()
func (p *pool) casIdleHead(o, n unsafe.Pointer) bool {
    // 實現非常簡單,就是利用了atomic.CompareAndSwapPointer()函數,p.idles指向了第一個協程,
    // 目標是讓p.idles指向n,o是以前的隊列頭,CAS就是如果p.idles==o則p.idles=n,否則返回false
    return atomic.CompareAndSwapPointer((*unsafe.Pointer)(unsafe.Pointer(&p.idles)), o, n)
}

// popIdle彈出隊列的第一個協程
func (p *pool) popIdle() *coroutine {
    for {
        // 和插入隊列一樣,都要判斷是否爲清理狀態,此處就不多解釋了。因爲隊列可能爲空,
        // 所以要判斷是否爲空
        head, cleaning := p.idleHead()
        if nil == head {
            return nil
        } else if cleaning {
            runtime.Gosched()
            continue
        }

        // 下面的操作讓p.idles指向head.next,等同於將head從隊列中移除
        c := (*coroutine)(head)
        if next := c.loadNext(); p.casIdleHead(head, next) {
            // 返回隊列頭部的協程,不難發現協程隊列其實是個棧(FILO),而隊列應該是FIFO,
            // 其實是棧還是隊列並不重要,重要的是無鎖隊列是一個廣爲人知的名字,熟悉無鎖隊列
            // 的讀者可以立刻想象到本文所描述的實現方案。
            c.storeNext(nil)
            return c
        }
    }
}

var cleaning = &coroutine{}

// idleHead返回隊列第一個協程,即隊列頭
func (p *pool) idleHead() (unsafe.Pointer, bool) {
    // p.idles指向了第一個協程,用原子的方式讀取隊列頭,因爲多個協程都在操作p.idles實現
    // 隊列的push和pop操作
    head := atomicLoadCoroutine(&p.idles)
    // 這句就是本文標題中簡約而不簡單的部分了,cleaning是全局變量,上面有定義,如果隊列頭指向
    // cleaning表示協程池正在執行清理函數。那麼問題來了,爲什麼要用這種方式?因爲所有的協程
    // 都在用CAS的方式操作隊列頭,也就是說只有隊列頭實現了全局狀態的一致性,但凡引入任何其他變量,
    // 都無法通過原子的方式同時操作隊列和該變量,此時就必須要加鎖,這不是筆者想要的。有的同學可能
    // 會問,空閒協程已經通過隊列的方式組織起來了,直接遍歷不就完了?答案肯定是不行的,因爲遍歷
    // 隊列中的任何一個協程都需要多個操作,當判斷協程超時的時候可能已經被調度了新的Routine,此時又
    // 要重頭開始。前面也提到了,多協程併發的操作隊列頭,隊列的前排是狀態變化最頻繁的,遍歷隊列的
    // 過程中可能一直在前排繞圈,因爲他們一直都在變化。而在隊列頭部插入一個特殊的協程,那麼所有
    // 操作隊列頭的協程(清理協程除外)都會進入等待狀態(就是前面提到的自旋),直到這個特殊的協程
    // 被彈出。
    return head, cleaning == (*coroutine)(head)
}

清理

// 終於到了清理函數了,來看看清理函數有沒有前面提到的非常快
func (p *pool) clean(now time.Time) {
    // 經過無鎖隊列章節的說明,這句就非常好理解了,把cleaning這個特殊的協程放入隊列頭部
    p.pushIdle(cleaning)

    // 從cleaning.next開始遍歷,cleaning.next就是pushIdle之前的隊列頭,此處需要注意,
    // from是c的前一個協程,即from.next==c.
    // 需要了解一點,下面這個for循環相當於只有一個清理協程在工作,其他的協程都在自旋狀態,
    // 理論上可以不用原子操作。
    for from, c := cleaning, cleaning.next; nil != c; from, c = c, c.next {
        // 這個應該不用解釋了,判斷空閒時間是不是超時?
        if now.Sub(c.active) >= p.IdleTimeout {
            // from之後的所有協程全部被刪除,爲什麼?前面提到過,協程池的數據結構是棧,越
            // 靠後面的協程是越先被插入,也就是空閒的時間越長,所以只要某一個協程超時,那麼
            // 該協程後面的所有協程肯定都超時。
            from.storeNext(nil)

            // 遍歷所有超時協程並通知退出
            var count int32
            for c != nil {
                c.rc <- nil
                c, c.next = c.next, nil
                count++
            }

            // 從協程的總數中減去已經退出的協程數量
            atomic.AddInt64(&p.count, -int64(count))
            break
        }
    }

    // 把cleaning協程從隊列中彈出,恢復狀態,不難看出,清理協程的函數最多就是遍歷一次所有空閒
    // 協程,總體來看是比較快的
    atomicStoreCoroutine(&p.idles, unsafe.Pointer(cleaning.next))
}

利用無鎖隊列實現的協程池,簡約而不簡單

前言

衆所周知,golang協程的創建、銷燬、調度是非常輕量的,但是即便再輕量,規模大了開銷也不能忽視的。比如利用協程處理http請求,每個請求用一個協程處理,當QPS上萬的時候,資源消耗還是比較大的。

協程池和線程池一樣,池子中都是熱協程,需要的時候取出來,用完後歸還,避免了高頻率的創建和銷燬。同時協程池還能將空閒超時的協程銷燬來釋放資源,並且還有一定保護能力,通過設定協程最大數量避免無休止的創建協程把系統資源消耗殆盡。

總之,golang雖然提供了非常輕量且容易使用的協程編程環境,但是不同的應用場景對於協程的使用需求也是不同的,協程池就是一種非常通用的應用場景。

無鎖隊列

在介紹協程池的實現之前需要簡單說明一下無鎖隊列,關於無鎖隊列的實現網上有很多文章,此處只簡單的說一些根本文實現有關的重點內容:CAS操作——Compare & Set,或是 Compare & Swap,現在幾乎所有的CPU指令都支持CAS的原子操作,X86下對應的是 CMPXCHG 彙編指令。有了這個原子操作,就可以用其來實現各種無鎖(lock free)的數據結構,本文使用的就是atomic.CompareAndSwapPointer。

實現

本文實現的源碼開源連接爲:https://github.com/jinde-zgm/gopool.git,接下來進入CAAD(代碼即文檔)模式,通篇只有代碼和註釋!需要解釋一點,開源代碼是筆者興趣使然用業餘時間寫的,並不涉及到任何工作相關的內容。

接口

// Pool定義了協程池的接口
type Pool interface {
    Name() string             // 獲取協程池的名字,當有多個協程池對象的時候可以用名字區分不同的協程池
    Capacity() int32          // 獲取協程池的容量,即最大協程數量
    Tune(size int32)          // 修改協程池的容量
    Status() Status           // 獲取協程池的狀態,關於狀態下面有定義
    Go(Routine) error         // 執行(阻塞)Routine,關於Routine下面也有定義
    GoNonblock(Routine) error // 非阻塞執行Routine,當協程數量達到最大值且無空閒協程時立刻返回
    Close()                   // 關閉協程池
}

// Routine定義了協程池執行的函數,context避免協程池關閉的時候協程被阻塞,也就是說協程池的使用者
// 需要將函數實現成Routine形式才能被協程池調用,
type Routine func(context.Context)

// Status定義了協程池的狀態
type Status struct {
    Runnings int32 // 運行中的協程數量
    Idles    int32 // 空閒的協程數量
}

協程

// coroutine 定義了協程
type coroutine struct {
    rc     chan Routine  // Routine的chan,Pool.Go(Routine)通過rc傳遞給協程執行
    pool   *pool         // 協程池指針,每個協程通過pool指向協程池(pool是Pool的實現)
    active time.Time     // 活躍時間,最後一次執行完Routine的時間,用於清理空閒超時的協程
    next   *coroutine    // 下一個協程,所謂無鎖隊列就是用這個變量將協程形成了隊列
}

// run是協程的運行函數
func (c *coroutine) run() {
    // 此處只需要知道pool有一個sync.WaitGroup的成員變量wg,用來等待所有協程退出,
    // 所以協程退出的時候需要調動Done
    defer c.pool.wg.Done()

    // 前面提到了,通過chan Routine獲取函數
    for r := range c.rc {
        // 空指針表示協程需要退出,比如協程池關閉或者協程空閒超時都會收到nil
        if r == nil {
            return
        }

        // 執行Routine,此處傳入了協程池的context,建議Routine的實現select該context
        r(c.pool.ctx)

        // 執行完函數,將該協程放到協程池的空閒隊列,此處開始進入本文的核心內容了
        c.pool.pushIdle(c)
    }
}

無鎖隊列

// pushIdle把協程放入空閒協程隊列
func (p *pool) pushIdle(c *coroutine) {
    // 此時協程已經執行完Routine,需要記錄一下最後的活躍時間
    c.active = time.Now()
    for {
        // 獲取空閒隊列的第一個協程,即隊列頭,clean表示協程池是否正在清理空閒隊列
        head, clean := p.idleHead()
        if clean {
            // 如果協程池正在清理空閒協程,需要等清理完畢後再把協程放入到空閒隊列中,
            // 如何才能知道協程池清理完了呢?chan或者sync.Cond應該是比較容易想到的方案,
            // 筆者採用了自旋的方案,因爲清理空閒協程非常快且不頻繁,自旋是性能最好的方法。
            // 此處使用了runtime.Gosched()實現自旋,此時立查詢清理是否完成多半還是在清理中,
            // 倒不如把時間片讓出來給其他協程,實在沒事幹了再去查詢清理狀態會更有效的利用CPU。
            // runtime.Gosched()會讓協程釋放CPU時間片,筆者此處問一個問題,如果不調用該函數,
            // 採用死循環的方式自旋查詢清理狀態(即把runtime.Gosched()註釋掉)是否可行,
            // 答案是不行的,原因讀者應該能夠想明白。
            runtime.Gosched()
            continue
        }

        // 到這裏說明協程池不在清理狀態,c.storeNext(head)是用c.next->head(當前),
        // p.casIdleHead(head, unsafe.Pointer(c))利用CAS操作實現p.idles->c.next,
        // 相當於把c放入了隊列頭,c.next指向了以前的隊列頭。因爲CAS是原子操作,無需用鎖互斥
        // 就可以把協程放入隊列,這也是無鎖隊列的由來
        if c.storeNext(head); p.casIdleHead(head, unsafe.Pointer(c)) {
            // 運行中的協程數量-1,通過源自操作計數,因爲執行上面是多個協程併發執行的
            // 此處需要注意,在清理超時協程的時候回插入cleaning協程,不能計爲運行中的協程
            if c != cleaning {
                atomic.AddInt64(&p.count, int64(-1)<<32)
            }
            break
        }
    }
}

// casIdleHead利用CAS實現協程池頭指針的操作,casIdleHead不僅可以實現插入協程到隊列頭,
// 同時可以將隊列頭協程彈出,詳情見下面的popIdle()
func (p *pool) casIdleHead(o, n unsafe.Pointer) bool {
    // 實現非常簡單,就是利用了atomic.CompareAndSwapPointer()函數,p.idles指向了第一個協程,
    // 目標是讓p.idles指向n,o是以前的隊列頭,CAS就是如果p.idles==o則p.idles=n,否則返回false
    return atomic.CompareAndSwapPointer((*unsafe.Pointer)(unsafe.Pointer(&p.idles)), o, n)
}

// popIdle彈出隊列的第一個協程
func (p *pool) popIdle() *coroutine {
    for {
        // 和插入隊列一樣,都要判斷是否爲清理狀態,此處就不多解釋了。因爲隊列可能爲空,
        // 所以要判斷是否爲空
        head, cleaning := p.idleHead()
        if nil == head {
            return nil
        } else if cleaning {
            runtime.Gosched()
            continue
        }

        // 下面的操作讓p.idles指向head.next,等同於將head從隊列中移除
        c := (*coroutine)(head)
        if next := c.loadNext(); p.casIdleHead(head, next) {
            // 返回隊列頭部的協程,不難發現協程隊列其實是個棧(FILO),而隊列應該是FIFO,
            // 其實是棧還是隊列並不重要,重要的是無鎖隊列是一個廣爲人知的名字,熟悉無鎖隊列
            // 的讀者可以立刻想象到本文所描述的實現方案。
            c.storeNext(nil)
            return c
        }
    }
}

var cleaning = &coroutine{}

// idleHead返回隊列第一個協程,即隊列頭
func (p *pool) idleHead() (unsafe.Pointer, bool) {
    // p.idles指向了第一個協程,用原子的方式讀取隊列頭,因爲多個協程都在操作p.idles實現
    // 隊列的push和pop操作
    head := atomicLoadCoroutine(&p.idles)
    // 這句就是本文標題中簡約而不簡單的部分了,cleaning是全局變量,上面有定義,如果隊列頭指向
    // cleaning表示協程池正在執行清理函數。那麼問題來了,爲什麼要用這種方式?因爲所有的協程
    // 都在用CAS的方式操作隊列頭,也就是說只有隊列頭實現了全局狀態的一致性,但凡引入任何其他變量,
    // 都無法通過原子的方式同時操作隊列和該變量,此時就必須要加鎖,這不是筆者想要的。有的同學可能
    // 會問,空閒協程已經通過隊列的方式組織起來了,直接遍歷不就完了?答案肯定是不行的,因爲遍歷
    // 隊列中的任何一個協程都需要多個操作,當判斷協程超時的時候可能已經被調度了新的Routine,此時又
    // 要重頭開始。前面也提到了,多協程併發的操作隊列頭,隊列的前排是狀態變化最頻繁的,遍歷隊列的
    // 過程中可能一直在前排繞圈,因爲他們一直都在變化。而在隊列頭部插入一個特殊的協程,那麼所有
    // 操作隊列頭的協程(清理協程除外)都會進入等待狀態(就是前面提到的自旋),直到這個特殊的協程
    // 被彈出。
    return head, cleaning == (*coroutine)(head)
}

清理

// 終於到了清理函數了,來看看清理函數有沒有前面提到的非常快
func (p *pool) clean(now time.Time) {
    // 經過無鎖隊列章節的說明,這句就非常好理解了,把cleaning這個特殊的協程放入隊列頭部
    p.pushIdle(cleaning)

    // 從cleaning.next開始遍歷,cleaning.next就是pushIdle之前的隊列頭,此處需要注意,
    // from是c的前一個協程,即from.next==c.
    // 需要了解一點,下面這個for循環相當於只有一個清理協程在工作,其他的協程都在自旋狀態,
    // 理論上可以不用原子操作。
    for from, c := cleaning, cleaning.next; nil != c; from, c = c, c.next {
        // 這個應該不用解釋了,判斷空閒時間是不是超時?
        if now.Sub(c.active) >= p.IdleTimeout {
            // from之後的所有協程全部被刪除,爲什麼?前面提到過,協程池的數據結構是棧,越
            // 靠後面的協程是越先被插入,也就是空閒的時間越長,所以只要某一個協程超時,那麼
            // 該協程後面的所有協程肯定都超時。
            from.storeNext(nil)

            // 遍歷所有超時協程並通知退出
            var count int32
            for c != nil {
                c.rc <- nil
                c, c.next = c.next, nil
                count++
            }

            // 從協程的總數中減去已經退出的協程數量
            atomic.AddInt64(&p.count, -int64(count))
            break
        }
    }

    // 把cleaning協程從隊列中彈出,恢復狀態,不難看出,清理協程的函數最多就是遍歷一次所有空閒
    // 協程,總體來看是比較快的
    atomicStoreCoroutine(&p.idles, unsafe.Pointer(cleaning.next))
}

Go

// 無論是Go還是GoNonblock,最終調用的都是goRoutine,無非是nonblocking是true還是false
func (p *pool) goRoutine(r Routine, nonblocking bool) error {
    // 如果協程池不在運行狀態,返回協程池已關閉錯誤
    if !p.state.is(stateRunning) {
        return ErrPoolClosed
    }

    // 從空閒隊列中彈出第一個協程
    var c *coroutine
    for c = p.popIdle(); nil == c; c = p.popIdle() {
        // 無空閒協程,就需要創建新的協程了,前提條件是協程數量沒有超過最大值,
        if count := atomic.LoadInt64(&p.count); int32(count) >= p.Capacity() {
            // 如果協程總量已經達到最大值,如果是nonblock則直接返回協程滿錯誤
            if nonblocking {
                return ErrPoolFull
            }
            
            // 否則自旋的方式再嘗試獲取空閒協程
            runtime.Gosched()
            
            // atomic.CompareAndSwapInt64(&p.count, count, count+1)就是協程總數+1,
            // 下面的語句如果執行失敗,說明其他人搶在前面創建或者有新的空閒協程,因爲協程
            // 計數發生變化,需要重新循環判斷
        } else if atomic.CompareAndSwapInt64(&p.count, count, count+1) {
            // 創建新協程,此處cache是sync.Pool,可以避免頻繁的申請和釋放內存
            c = p.cache.Get().(*coroutine)
            // 創建了新協程,wg就要+1
            p.wg.Add(1)
            go c.run()
            break
        }
    }

    // 增加運行中的協程計數並把Routine傳給協程
    atomic.AddInt64(&p.count, int64(1)<<32)
    c.rc <- r
    return nil
}

總結

以上就是利用無鎖隊列實現的協程池的重點代碼,其他的代碼主要是輔助作用,此處就不一一解釋了,有問題的可以聯繫筆者。先做一個簡單的總結:

  1. 無鎖隊列只是個代名詞,真實是一個無鎖棧;
  2. 爲什麼是單向指針(next)而不是雙向指針(prev,next),因爲CAS只能操作一個指針,熟悉LevelDB的同學應該知道,MemTable使用的是跳躍表(SkipList)而不是map,其原因是跳躍表的指針也是單向指針,LevelDB使用的是內存屏障技術而不是CAS,這樣就避免鎖的操作,因爲map是線程不安全的;
  3. 所有的等待其實是自旋,包括等待清理,等待空閒協程,看似無鎖,其實是自旋鎖。

其實本文的方案存在一個缺點,那就是自旋(成也蕭何敗也蕭何)。等待清理的自旋並沒有什麼,畢竟清理非常快,而且清理週期相對協程的調度週期大很多。筆者指的是等待空閒協程的自旋,當協程池滿且所有運行協程都被某些事件阻塞,此時所有等待空閒協程的請求都在自旋的查詢隊列,相當於空轉。此時本應CPU使用率很低但因爲這些自旋的協程導致CPU使用率非常高,但是這並不會對程序有什麼影響,但凡有任何協程被喚醒他們都會讓出CPU時間片。說簡單點,就是這些等待的協程在利用別人不用的CPU自旋,對於完美主義者的筆者來說雖然有一些遺憾,但是能接受。

筆者做過簡單的測試,本文提到的協程池方案調度性能還是比較高的(比ants還要高10%)。至於協程池能用來幹什麼,以後會陸續介紹較大規模任務調度中使用協程池的方法,敬請期待。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章