Skip to content

Latest commit

 

History

History
200 lines (159 loc) · 8.83 KB

2-sync-v2.md

File metadata and controls

200 lines (159 loc) · 8.83 KB

Package sync/v2 for generics? [DRAFT]

This article, we wil explore how the sync package (and sync/atomic) might look like, if upgraded to support generics through package versioning. This is not a formal proposal, but rather part of a larger discussion on Go's GitHub project. For a quick introduction to the generics proposal in Go, and to the flavor of versioning used in this series of articles, please read the introduction article.

How to allow interoperability

Many packages today would likely refer directly to types, such as the sync.Mutex or sync.ReadWriteMutex types. Most of the time, it would do so internally, and not take these types as parameters, but there might be cases that do so. Ideally, it would be possible to use a sync.Mutex from sync/v2 as a drop-in replacement for a sync.Mutex. Luckily Go allows type aliases, so if we move the definition of sync.Mutex and other types to the v2 package the v1 package could use a type alias to refer to it.

If type aliases where to support type parameterization, then we could perhaps apply similar tactics to types that are of interest to converted to generics; e.g. sync.Map. This way we could reduce code duplication as well as binary bloat for programs that ends up including both package versions.

sync/v2

To imagine how a sync/v2 package, might look like, let's recap how the sync package looks today. Below is a listing of the public interface of the package as of Go 1.17:

type Cond
	func NewCond(l Locker) *Cond
	func (c *Cond) Broadcast()
	func (c *Cond) Signal()
	func (c *Cond) Wait()
type Locker
type Map
	func (m *Map) Delete(key interface{})
	func (m *Map) Load(key interface{}) (value interface{}, ok bool)
	func (m *Map) LoadAndDelete(key interface{}) (value interface{}, loaded bool)
	func (m *Map) LoadOrStore(key, value interface{}) (actual interface{}, loaded bool)
	func (m *Map) Range(f func(key, value interface{}) bool)
	func (m *Map) Store(key, value interface{})
type Mutex
	func (m *Mutex) Lock()
	func (m *Mutex) Unlock()
type Once
	func (o *Once) Do(f func())
type Pool
	func (p *Pool) Get() interface{}
	func (p *Pool) Put(x interface{})
type RWMutex
	func (rw *RWMutex) Lock()
	func (rw *RWMutex) RLock()
	func (rw *RWMutex) RLocker() Locker
	func (rw *RWMutex) RUnlock()
	func (rw *RWMutex) Unlock()
type WaitGroup
	func (wg *WaitGroup) Add(delta int)
	func (wg *WaitGroup) Done()
	func (wg *WaitGroup) Wait()

We can imagine that we move all of this code into a sub-folder sync/v2. Everything remain the same, except the Map and Pool types which are rewritten to support generics:

type Map[K,V any]
	func (m *Map[K,V]) Delete(key K)
	func (m *Map[K,V]) Load(key K) (value V, ok bool)
	func (m *Map[K,V]) LoadAndDelete(key K) (value V, loaded bool)
	func (m *Map[K,V]) LoadOrStore(key K, value V) (actual V, loaded bool)
	func (m *Map[K,V]) Range(f func(key K, value V) bool)
	func (m *Map[K,V]) Store(key K, value V)
type Pool[V any]
	func (p *Pool[V]) Get() V
	func (p *Pool[V]) Put(x V)

As it turns out, the implementation of the sync v1 package can the probably be recreated in just 10 lines of code:

package sync

import "sync/v2"

type Cond = sync.Cond
type Locker = sync.Locker
type Map = sync.Map[interface{},interface{}]
type Mutex = sync.Mutex
type Once = sync.Once
type Pool = sync.Pool[interface{}]
type RWMutex = sync.RWMutex
type WaitGroup = sync.WaitGroup[]

Now, let's move on to the only sub-package.

sync/v2/atomic

As mentioned in the introduction article, this series always introduce the versioning after the first element, and as a consequence, if we want to version the sync package, we also have to version the sync/atomic package. Wether this package needs generics is a discussion in itself, but let's for now assume that we have choosen to add generics to the entire public interface. How could we do that?

To refresh, this is how the public interface for the package looks like today:

func AddInt32(addr *int32, delta int32) (new int32)
func AddInt64(addr *int64, delta int64) (new int64)
func AddUint32(addr *uint32, delta uint32) (new uint32)
func AddUint64(addr *uint64, delta uint64) (new uint64)
func AddUintptr(addr *uintptr, delta uintptr) (new uintptr)
func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)
func CompareAndSwapInt64(addr *int64, old, new int64) (swapped bool)
func CompareAndSwapPointer(addr *unsafe.Pointer, old, new unsafe.Pointer) (swapped bool)
func CompareAndSwapUint32(addr *uint32, old, new uint32) (swapped bool)
func CompareAndSwapUint64(addr *uint64, old, new uint64) (swapped bool)
func CompareAndSwapUintptr(addr *uintptr, old, new uintptr) (swapped bool)
func LoadInt32(addr *int32) (val int32)
func LoadInt64(addr *int64) (val int64)
func LoadPointer(addr *unsafe.Pointer) (val unsafe.Pointer)
func LoadUint32(addr *uint32) (val uint32)
func LoadUint64(addr *uint64) (val uint64)
func LoadUintptr(addr *uintptr) (val uintptr)
func StoreInt32(addr *int32, val int32)
func StoreInt64(addr *int64, val int64)
func StorePointer(addr *unsafe.Pointer, val unsafe.Pointer)
func StoreUint32(addr *uint32, val uint32)
func StoreUint64(addr *uint64, val uint64)
func StoreUintptr(addr *uintptr, val uintptr)
func SwapInt32(addr *int32, new int32) (old int32)
func SwapInt64(addr *int64, new int64) (old int64)
func SwapPointer(addr *unsafe.Pointer, new unsafe.Pointer) (old unsafe.Pointer)
func SwapUint32(addr *uint32, new uint32) (old uint32)
func SwapUint64(addr *uint64, new uint64) (old uint64)
func SwapUintptr(addr *uintptr, new uintptr) (old uintptr)
type Value
    func (v *Value) CompareAndSwap(old, new interface{}) (swapped bool)
    func (v *Value) Load() (val interface{})
    func (v *Value) Store(val interface{})
    func (v *Value) Swap(new interface{}) (old interface{})

Most obvious, perhaps, we could imagine rewriting the Value type to be generic:

type Value[T any]
    func (v *Value) CompareAndSwap(old, new T) (swapped bool)
    func (v *Value) Load() (val T)
    func (v *Value) Store(val T)
    func (v *Value) Swap(new T) (old T)

Value is an interesting case because the implementation it actually not controlling atomic access to the type itself, but uses pointers to give us an illusion that it does. We could be discussing if we would really want to simlply type parammetrize the type with no further changes, but for the scope of this article, let's assume that's exactly what we want

As far as I have been able to read in the discussion, this is as far as the proposals go in terms of transferring the sync/atomic package to use generics. However, it doesn't need to end there. In fact, we can perhaps manage reduce the number of public functions from 29 to just 5 by introducing a few type set constraints:

type Integer interface{
	~int32 | ~uint32 | ~int64 | ~uint64 | ~uintptr
}
type Atomic interface{
	~int32 | ~uint32 | ~int64 | ~uint64 | ~uintptr | unsafe.Pointer
}

func Add[T Integer](addr *T, delta T) (new T)
func CompareAndSwap[T Integer](addr *T, old, new T) (swapped bool)
func Load[T Atomic](addr *T) (val T)
func Store[T Atomic](addr *T, val T)
func Swap[T Atomic](addr *T, new T) (old T)

I say perhaps, because the devil here is in the detail. None of these functions are actually implemented in the sync/atomic package, but rather in the internal portions of the Go runtime, and we do need them to be fast. Therefore we don't want to depend on explicit type-switching in Go -- which would include a potenitally expensive branch condition -- to determine which runtime method to call; instead, we need this to be solved compile-time.

In the current sync atomic package, we refer to the runtime implementation using the Go assembly syntax in a file called asm.s. If the generic interface for this function is to work, then the assembly syntax itself must be extended to support type parameterization values in the function names. E.g.:

TEXT ·Swap[int32](SB),NOSPLIT,$0
	JMP	runtime∕internal∕atomic·Xchg(SB)

TEXT ·Swap[uint32](SB),NOSPLIT,$0
	JMP	runtime∕internal∕atomic·Xchg(SB)

TEXT ·Swap[int64](SB),NOSPLIT,$0
	JMP	runtime∕internal∕atomic·Xchg64(SB)

TEXT ·Swap[uint64](SB),NOSPLIT,$0
	JMP	runtime∕internal∕atomic·Xchg64(SB)

TEXT ·Swap[uintptr](SB),NOSPLIT,$0
	JMP	runtime∕internal∕atomic·Xchguintptr(SB)

With this done, sync/atomic can either be changed to become a wrapper for sync/v2/atomic:

package sync

import "sync/v2/atomic"

func AddInt32(addr *int32, delta int32) (new int32) {
	return atomic.Add(addr, delta)
}
func AddInt64(addr *int64, delta int64) (new int64) {
	return atomic.Add(addr, delta)
}

// And so on...

type Value = atomic.Value[interface{}]

Or retain it's current assembly mapping for the functions and define a type alias only for Value.