Understanding Static and Dynamic Libraries(plugins) in Golang
Introduction:
In this blog post, we will be learning about static and dynamic linking (plugin systems) in Golang, discussing the fundamental concepts, the difference between static and dynamic libraries, and the challenges faced when using dynamic linking in Go.
Static Libraries vs. Dynamic Libraries in Golang:
Static Libraries —
In Go, static libraries are linked to an application during compile time. The contents of the library, including variables and functions, are copied into the target application. Let’s illustrate this with a simple example using a calculator library:
Our Calculator Library will expose a function Calculate which will compute the sum of given numbers.
func Calculate(num... int) {
ans := 0
for _, n := range num {
ans += n
}
fmt.Println(ans)
}
Now, if we import this library and try to invoke Calculate function that will result in a static linking of the library to our main application.
import "calculator/add"
func main() {
add.Calculate(2,4)
}
This means that the Calculator function is now a part of our application build and if we make any further changes to the Calculator function we will need to re-compile our main application in order to reflect those changes.
Dynamic libraries —
In Go, Dynamic libraries or shared libraries exist as separate files outside of the host executable files and are loaded from those shared object files at runtime. Golang provides this capability in the form of plugins. To understand this better, let’s convert our static calculator library to a dynamic one but first let’s discuss a few fundamental concepts of a plugin system.
Fundamental Concepts of Plugins —
- Discovery:
Discovery is the process by which an application identifies the available plugins. It involves searching specific locations and understanding what to look for in terms of plugin identification. - Registration:
Registration allows a plugin to inform an application that it is ready to perform certain tasks. Although registration overlaps with discovery in some cases, separating the two concepts helps make the process more explicit. - Hooks (Mount Points):
Hooks, also known as mount points or extension points, are the designated places in an application where plugins can attach themselves. These hooks allow plugins to participate in the application’s flow and respond to specific events. The nature of hooks varies depending on the application’s design. - Exposing Application Capabilities to Plugins (Extension API):
For plugins to be powerful and versatile, the application needs to expose an API that plugins can utilize. This API enables communication and interaction between the application and the plugins, even in scenarios involving multiple languages.
Now that we have grasped the fundamentals of plugins let’s convert our static calculator library to dynamic.
Following are the code changes required to make this library dynamic-linkable -
type calculator struct {}
func (c *calculator) Calculate(num... int) {
ans := 0
for _, n := range num {
ans += n
}
fmt.Println(ans)
}
var Calculator calculator
We exposed a struct which implements the method Calculate. This is required for the discovery and registration of the plugin. Now to compile this and make it a shared-object we need to build the library as a plugin.
go build -o -buildmode=plugin .
After this, we will have a shared object file(in our case “add.so”) which we can dynamically load at runtime in our main application.
Now to dynamically load the library into our main application at run time we need use the plugin system of Golang.
import (
"fmt"
"os"
"plugin"
)
type Calculator interface {
Calculate(num... int)
}
func main() {
calculatorPlugin, err := plugin.Open("/abs/path/to/shared/object/file")
if err != nil {
fmt.Println("error while opening shared object file")
os.Exit(1)
}
symCalculator, err := calculatorPlugin.Lookup("Calculator")
if err != nil {
fmt.Println("error while lookup")
os.Exit(1)
}
var calculator Calculator
calculator, ok := symCalculator.(Calculator)
if !ok {
fmt.Println("unexpected type from module symbol")
os.Exit(1)
}
calculator.Calculate(3,4)
}
In this case if we are making any changes to library, we don’t need to re-compile our main application. We only need to recompile the library’s shared object and rerun our main application.
Due to the different characteristics, the advantages and disadvantages of static and dynamic libraries are also obvious; binaries that rely only on static libraries and are generated by static linking can be executed independently because they contain all the dependencies, but the compilation result is also larger. Dynamic libraries can be shared among multiple executables, which can reduce the memory footprint, and their linking process is often triggered during loading or running, so they can contain some modules that can be hot-plugged and reduce the memory footprint. Compiling binaries using static linking has very obvious deployment advantages, and the final compiled product will run directly on most machines. The deployment benefits of static linking are far more important than the lower memory footprint, that’s why Golang uses static linking as the default linking method.
Issues with Dynamic-linking (shared library plugins) in Go
Plugins using shared libraries and the plugin package work well for Golang, as the previous section demonstrates. However, this approach also has some serious downsides. The most important downside is that Golang is very picky about keeping the main application and the shared libraries it loads compatible.
As an experiment, try using different versions of a common dependency in the plugin application and the main application, rebuild the main application and run it. Most likely you’ll get this error:
"plugin was built with a different version of package XXX"
The reason for this is that Golang wants all the versions of all packages in the main application and plugins to match exactly. It’s clear what motivates this: safety.
Consider C and C++ as counter-examples. In these languages, an application can load a shared library with dlopen and subsequently use dlsym to obtain symbols from it. dlsym is extremely weakly typed; it takes a symbol name and returns a void*. It’s up to the user to cast this to a concrete function type. If the function type changes because of a version update, the result can very likely be some sort of segmentation fault or even memory corruption.
Since Golang relies on shared libraries from plugins, it has the same inherent safety issues. It tries to protect programmers from shooting themselves in the foot by ensuring that the application has been built with the same versions of packages as all its plugins. This helps avoid mismatch. In addition, the version of the Golang compiler used to build the application and plugins must match exactly.
However, this protection comes with downsides — making developing plugins somewhat cumbersome. Having to rebuild all plugins whenever any common packages change — even in ways that don’t affect the plugin interface — is a heavy burden. Especially, considering that by their very nature plugins are typically developed separately from the main application; they may live in separate repositories, have separate release cadences etc.
Alternative approaches to shared library plugins in Golang
Given that the plugin package was only added to Go in version 1.8 and the limitation described previously, it’s not surprising that the Go ecosystem saw the emergence of alternative plugin approaches.
One of the innovative approaches involves plugins via RPC. In this method instead of loading the plugins into the host process we load them in a separate process which then communicates to host via RPC or just TCP on localhost. It has several important upsides:
- Isolation: crash in a plugin does not bring the whole application down.
- Interoperability between languages: if RPC is the interface, do you care what language the plugin is written in?
- Distribution: if plugins interface via the network, we can easily distribute them to run on different machines for gains in performance, reliability, and so on.
Moreover, Golang makes this particularly easy by having a fairly capable RPC package right in the standard library: net/rpc.
One of the most widely used RPC-based plugin systems is hashicorp/go-plugin. Hashicorp is well known for creating great Golang software, and apparently, they use go-plugin for many of their systems, so it’s battle-tested (though its documentation could be better 😛)
Golang-plugin runs on top of net/rpc but also supports gRPC. Advanced RPC protocols like gRPC are well suitable for plugins because they include versioning out-of-the-box, tackling the difficult interoperability problem between different versions of plugins vs. the main application.
However, this is also not a perfect solution as we talk about the fourth fundamental of plugin systems- “Extension APIs”. In a complex system, a plugin might make lot of Extension APIs calls which will end up increasing the latency if we are making network calls via RPC or TCP.
Conclusion
Golang still has a long way to go when it comes to Dynamic-linking (shared library plugins). No solution discussed in this blog can be considered perfect. One needs to consider the pros and cons of each solution available as per their specific requirements.