gorustshared-libraries

Go shared library doesn't get reloaded to newest version


I have go shared library like this

plugin.go

package main

/*
#include <stdlib.h>
#include <stdio.h>
*/
import "C"
import (
    "fmt"
)

func init() {
    fmt.Printf("[PLUGIN] Loaded go plugin")
}

func GetMessage() *C.char {
    msg := fmt.Sprintf("Hello from go plugin v1!")
    return C.CString(msg)
}

func main() {}

And then I build the go shared library with this bash script

#!/bin/bash
set -e

echo "Building plugin..."
go build -buildmode=c-shared -o plugin.so plugin/plugin.go
echo "Build complete: plugin.so"

chmod +x plugin.so

I also have Rust shared library like this

lib.rs

use std::ffi::CString;
use std::os::raw::c_char;

#[unsafe(no_mangle)]
pub extern "C" fn init() {
    println!("[PLUGIN] Loaded rust plugin");
}

#[unsafe(no_mangle)]
pub extern "C" fn GetMessage() -> *const c_char {
    let message = "Hello from rust plugin v1!";
    let c_string = CString::new(message).unwrap();
    c_string.into_raw()
}

And then I have another rust shared library that act as bridge that will reload plugin.so that can be from go plugin or rust plugin in runtime

lib.rs (bridge)

use libloading::{Library, Symbol};
use std::ffi::CStr;
use std::os::raw::c_char;
use std::sync::Mutex;

lazy_static::lazy_static! {
    static ref LIB: Mutex<Option<Library>> = Mutex::new(None);
}

#[unsafe(no_mangle)]
pub unsafe extern "C" fn load_plugin(path: *const c_char) -> *const c_char {
    let c_str = CStr::from_ptr(path);
    let path_str = match c_str.to_str() {
        Ok(s) => s,
        Err(_) => return std::ptr::null(),
    };

    let lib = match Library::new(path_str) {
        Ok(lib) => lib,
        Err(_) => return std::ptr::null(),
    };

    let func: Symbol<unsafe extern "C" fn() -> *const c_char> = match lib.get(b"GetMessage") {
        Ok(f) => f,
        Err(_) => return std::ptr::null(),
    };

    let msg = func();

    let mut global = LIB.lock().unwrap();
    *global = Some(lib);

    msg
}

#[unsafe(no_mangle)]
pub extern "C" fn unload_plugin() {
    let mut global_lib = LIB.lock().unwrap();
    *global_lib = None;
}

And then I have main.go that will call bridge.so and then bridge.so can swap between rust plugin.so and go plugin.so

main.go

package main

/*
#cgo LDFLAGS: -L./bridge/target/release -lbridge -ldl
#include <stdlib.h>

const char* load_plugin(const char*);
void unload_plugin();
*/
import "C"
import (
    "bufio"
    "fmt"
    "os"
    "strings"
    "unsafe"
)

const pluginPath = "./plugin.so"

func load() {
    cPath := C.CString(pluginPath)
    defer C.free(unsafe.Pointer(cPath))

    msg := C.load_plugin(cPath)
    if msg != nil {
        goMsg := C.GoString(msg)
        fmt.Println("Plugin says:", goMsg)
    } else {
        fmt.Println("Gagal memuat plugin.")
    }
}

func unload() {
    C.unload_plugin()
    fmt.Println("Plugin di-unload.")
}

func main() {
    fmt.Println("Ketik perintah: load | reload | unload | exit")
    reader := bufio.NewReader(os.Stdin)

    for {
        fmt.Print("> ")
        input, _ := reader.ReadString('\n')
        input = strings.TrimSpace(input)

        switch input {
        case "load":
            load()
        case "reload":
            unload()
            load()
        case "unload":
            unload()
        case "exit":
            unload()
            fmt.Println("Keluar.")
            return
        default:
            fmt.Println("Perintah tidak dikenal.")
        }
    }
}

I run go run main.go and then build the rust plugin.so and then give argument "load", the message v1 plugin from rust appears

And then I update the rust plugin message to "hello from rust plugin v2", then rebuild the rust plugin.so

Then I give argument "reload" to the running main.go without restarting it, it successfully update the plugin to v2 on the fly by displaying "hello from rust plugin v2"

But It's not the same with the go plugin. I update the go plugin message to "hello from go plugin v2" and then rebuild the go plugin

Then I give argument "reload" to the running main.go without restarting it just like before, but the plugin is not updated, the message printed still "hello from go plugin v1" not "hello from go plugin v2"

Also the go plugin is not deleted from memory after I call "unload", where the rust plugin is deleted from memory successfully

I know go has plugin package, but it also doesn't allow to unload old version from the memory, I have to load the go plugin with different name to make it can run the newest version, but the old version is still in memory, I want to delete the old version because it will not be used again to free memory

I have succesfully made the old go plugin inaccessible to the main.go via the rust bride after I pass "unload", but when I inspect with

lsof -p pid | grep '\.so'

The old go plugin is still in the memory, just is not accessible from the main.go but not be freed from memory

I tried using c ldopen and ldclose, same as above when I inspect with that command, the result is like this

main    18107 root DEL       REG 253,50          2043636 /data/data/com.termux/files/usr/var/lib/proot-distro/installed-rootfs/archlinux/tmp/plugin_e8371e56.so
main    18107 root DEL       REG 253,50            60937 /data/data/com.termux/files/usr/var/lib/proot-distro/installed-rootfs/archlinux/tmp/plugin_09530d13.so

Displaying DEL REG but it continously appear even after I wait for some time then then inpecting again

Does anyone know why this happens and how to solve it?


Solution

  • Go uses weak symbols, so when a Go library is loaded into a Go executable it will use the executable's runtime, which holds onto it.

    one (very unsafe) way to get this to work is to load the library into another namespace with dlmopen(LM_ID_NEWLM), but this may only work on unix systems, this workaround may not work on windows and people have reported it crashing on windows.

    use std::ffi::CStr;
    use std::os::raw::c_char;
    use std::os::raw::c_void;
    use libc::{dlmopen, dlsym, dlclose, LM_ID_NEWLM};
    use std::ffi::CString;
    
    static mut LIB: *mut c_void = std::ptr::null_mut();
    
    #[unsafe(no_mangle)]
    pub unsafe extern "C" fn load_plugin(path: *const c_char) -> *const c_char {
        unload_plugin(); // reset LIB
    
        let lib = dlmopen(LM_ID_NEWLM, path, libc::RTLD_NOW);
        if lib == std::ptr::null_mut()
        {
            println!("failed to load lib!");
            return std::ptr::null();
        }
    
        LIB = lib;
    
        let rust_str = "GetMessage";
        let c_string = CString::new(rust_str).unwrap(); // Converts &str to CString
        let c_char_ptr = c_string.into_raw(); // Converts CString to *mut c_char
    
        let func_ptr = dlsym(lib, c_char_ptr);
        CString::from_raw(c_char_ptr); // clean up memory
    
        if func_ptr == std::ptr::null_mut()
        {
            println!("failed to load func!");
            return std::ptr::null();
        }
    
        let func = std::mem::transmute::<_,unsafe extern "C" fn() -> *const c_char>(func_ptr);
        let msg = func();
    
        msg
    }
    
    #[unsafe(no_mangle)]
    pub unsafe extern "C" fn unload_plugin() {
        if LIB != std::ptr::null_mut()
        {
            dlclose(LIB);
            LIB = std::ptr::null_mut();
        }
    }
    
    > load
    [PLUGIN] Loaded go plugin
    Plugin says: Hello from go plugin v1!
    > unload
    Plugin di-unload.
    > load
    [PLUGIN] Loaded go plugin
    Plugin says: Hello from go plugin v2!
    > unload
    Plugin di-unload.
    > 
    

    but there is no guarantee that there won't be any crashes in the future, this use-case was never really intended. this may crash with more code, you have been warned!


    My opinion on this is that it is better to embed a WASM runtime and use WASM for plugins, their performance is within 10-20% overhead over native performance and are being used in AAA games. and will give you portability and no headache from crashes.

    You can take a look at the list of wasm runtimes and pick one, wasmtime is currently the most popular and has Go bindings.