Introduction
In my previous post I described simple implementation of the Object Pool pattern. In this post I will extend this pattern using generics. It will become slightly more complicated, but also more flexible, and probably more solid.
Implementation in Go
We will start with the usual preliminaries:
import (
"fmt"
"log"
"sync"
)
Now, since we identify each item by an ID, we need to have an Identifiable interface. Because an ID can take many forms, all we need to know is whether it is comparable, so we can apply the ‘==’ operator to it:
type Identifiable[T comparable] interface {
getID() T
}
Now we can implement a concrete connection:
type ConcreteConnection struct {
id int16
}
func (c *ConcreteConnection) getID() int16 {
return c.id
}
Go considers the ConcreteConnection to be an implementation of Identifiable[int16]
The Pool object
The generic version of the ConnectionPool looks like this:
type ConnectionPool[U comparable, T Identifiable[U]] struct {
idleConnections []T
activeConnections []T
capacity int
poolLock *sync.Mutex
}
A few notes:
- Identifiable is now a generic struct, which takes a type argument, U in our case, which is comparable. That is the meaning of the rather complicated generic declaration.
- We need a pool of idleconnections, those are connections which are not in active use.
- Conversely we need to do some bookkeeping of our active connections
- Our pool has a maximum capacity. One nice extension would be to have a pool which grows or shrinks in size, depending on demand.
- Since our pool will be threadsafe, we will use a Mutex struct, to make sure only thread can access it at any given time.
Initializing the pool
The pool must be initialized:
func initializeConnectionPool[U comparable, T Identifiable[U]](initialConnections []T) (*ConnectionPool[U, T], error) {
if len(initialConnections) == 0 {
return nil, fmt.Errorf("Cannot create an empty pool")
}
activeConnections := make([]T, 0)
result := &ConnectionPool[U, T]{
idleConnections: initialConnections,
activeConnections: activeConnections,
capacity: len(initialConnections),
poolLock: new(sync.Mutex),
}
return result, nil
}
Some notes:
- The declaration of the generics looks complicated. The connection pool takes two arguments: U which is comparable, which is type of the ID, and the Identifiable[U] which is the interface of the objects in the pool, so we can use the getID() method.
- We initialize the idleconnection with some pre-constructed structs.
- When we initialize the pool there are no active connections
- The capacity is initially set to the size of the initialConnection
- Notice that there is some basic error handling.
The getConnection() method
The getConnection() method has changed quite a bit to make generics possible:
func (c *ConnectionPool[U, T]) getConnection() (T, error) {
c.poolLock.Lock()
defer c.poolLock.Unlock()
if len(c.idleConnections) == 0 {
return *new(T), fmt.Errorf("We found no free connections. Please retry later")
}
result := c.idleConnections[0]
c.idleConnections = c.idleConnections[1:]
c.activeConnections = append(c.activeConnections, result)
fmt.Printf("Gave out connection with ID: %d\n", result.getID())
return result, nil
}
Line by line:
- Remember: T implements the Identifiable interface, U the is the type of id that is returned
- First we lock our mutex, so no other threads can access our object.
- We use defer to make ensure that our mutex is unlocked whenever we exit this method.
- Then we check whether we have any idle connections left, if not we return an error
- If we have, we get the first connection, and update the slice to hold anything but the original first element. That is what the [1:] index does.
- Finally we return the result
Notice that we do not return a pointer here, but a concrete implementation of the object. This is because an interface is basically a pointer itself. Therefore we need to return a concrete object which implements the interface. For a good explanation of this, you can find it here.
The returnConnection() method
This method returns an object to the object pool:
func (c *ConnectionPool[U, T]) returnConnection(connection T) error {
c.poolLock.Lock()
defer c.poolLock.Unlock()
err := c.removeFromActive(connection)
if err != nil {
return err
}
c.idleConnections = append(c.idleConnections, connection)
return nil
}
This code does two things:
- Remove the connection, if possible, from the active connections
- Add the removed connection to the idleconnection slice.
Removing active connections: the removeFromActive() method
This method removes a connection from the active method slice:
func (c *ConnectionPool[U, T]) removeFromActive(connection T) error {
activeLength := len(c.activeConnections)
for i, conn := range c.activeConnections {
if conn.getID() == connection.getID() {
c.activeConnections[activeLength-1], c.activeConnections[i] = c.activeConnections[i], c.activeConnections[activeLength-1]
c.activeConnections = c.activeConnections[:activeLength-1]
return nil
}
}
return fmt.Errorf("Could not find connection with ID %d in active connection pool")
}
Line by line:
- We establish the length of our current active connection slice.
- Next we iterate over it. If we find the connection we do several things: 1. we move the connection to the end of the slice 2. Next we cut out the last element. 3. And since we found the element, we return nil, because there is no error.
- If we did not find the connection we return an error.
This way we keep our active connection slice compact, and with the correct number of connections.
Time to test
Now we can test it:
func main() {
connections := make([]Identifiable[int16], 2)
connections[0] = &ConcreteConnection{id: 1}
connections[1] = &ConcreteConnection{id: 2}
connectionPool, err := initializeConnectionPool[int16](connections)
if err != nil {
log.Fatalf("Failed to initialize connectionPool: %v\n", err)
}
firstConnection, err := connectionPool.getConnection()
if err != nil {
log.Printf("Failed to get connection from Pool: %v\n", err)
} else {
fmt.Printf("Got first connection from pool: %d\n", firstConnection.getID())
}
secondConnection, err := connectionPool.getConnection()
if err != nil {
log.Printf("Failed to get connection from Pool: %v\n", err)
} else {
fmt.Printf("Got second connection from pool: %d\n", secondConnection.getID())
}
thirdConnection, err := connectionPool.getConnection()
if err != nil {
log.Printf("Failed to get connection from Pool: %v\n", err)
} else {
fmt.Printf("Got third connection from pool: %d\n", thirdConnection.getID())
}
err = connectionPool.returnConnection(firstConnection)
if err != nil {
log.Printf("Failed to return connection to Pool: %v\n", err)
}
err = connectionPool.returnConnection(secondConnection)
if err != nil {
log.Printf("Failed to return connection to Pool: %v\n", err)
}
thirdConnection, err = connectionPool.getConnection()
if err != nil {
log.Printf("Failed to get connection from Pool: %v\n", err)
} else {
fmt.Printf("Got third connection from pool (2nd try): %d\n", thirdConnection.getID())
}
}
A short summary:
- We create a slice with two ConcreteConnection[int16[ struct. That means that the id of the connections is of type int16
- Next we initialize a pool with these connection
- After that we try getting connections from it, and returning them. Getting the third connection should initially fail, but after returning the other connections it should succeed.
Conclusion
Adding generics complicated things a bit, but not very much. The pattern is now much more flexible. The problem with this implementation is that getting and returning objects from the pool is very easy, however, returning objects to the pool is not enforeced. A way to enforce that will be the subject of another post.