17 July 2022
A Windows service is a program for Windows that runs in the background, without any user interface, like a demon process on Unix. If you’re on Windows you can see which services are running with the Services tool:
This article shows how to write a Windows service in Go by going through the source code for a service that simply prints a log message every 30 seconds. You can see the full sample code here: main.go
Windows services have to use a specific API -- you can’t just take any command-line program and set it up as a service. Fortunately, the Go package golang.org/x/sys/windows/svc has everything we need with a nice Go interface.
To write our sample service, we start by importing the library:
import "golang.org/x/sys/windows/svc"
Then we’ll define a couple of constants for errors that might occur:
const (
exitCodeDirNotFound = 1
exitCodeErrorOpeningLog = 2
)
In main
, all we need to do is call the svc.Run
function with a name for
the service and a “handler” that has the actual implementation:
func main() {
svc.Run("sample-service", new(handler))
}
The handler needs to implement the one-method
handler
interface. We can define it as an empty struct with the required method:
type handler struct{}
func (h *handler) Execute(args []string, r <-chan svc.ChangeRequest,
s chan<- svc.Status) (svcSpecificEC bool, exitCode uint32) {
As an aside, I’m not sure why they made this an interface rather than having the caller just pass a
function. Then we wouldn’t need that odd-looking handler
type. Anyways, let’s take a
look at the method parameters: args
is the equivalent to os.Args
for
comment-line programms, with the service name and any arguments passed to the service. The
r
and s
channels are used to communicate with the service control manager
(the part of Windows that, well, controls services) about the current status of the service.
The first thing we do is set the state to “start pending:”
s <- svc.Status{State: svc.StartPending}
Next, we’ll want some way to log messages about the service. The
Windows Event Log
is one option, but for this tutorial we’ll just use the standard library log
package
and write to a file in the directory where the service executable is:
executable, err := os.Executable()
if err != nil {
return true, exitCodeDirNotFound
}
dir := filepath.Dir(executable)
logPath := filepath.Join(dir, "service.log")
flag := os.O_APPEND|os.O_CREATE|os.O_WRONLY
logFile, err := os.OpenFile(logPath, flag, 0644)
if err != nil {
return true, exitCodeErrorOpeningLog
}
defer logFile.Close()
logger := log.New(logFile, "", log.LstdFlags)
Most of this is just calling standard library functions, except for those return statements:
return true, exitCodeErrorOpeningLog
The execute
method is a bit like the main
method for a service, so once it
returns, the service terminates. Like main
, it returns an exit code that’s supposed to
be zero for success and a non-zero value to signal a specific error. That’s the second return value;
the first one indicates if the exit code is a standard Windows error code or something specific to
this service. Here we just have a couple of error codes defined as constants, so the first return
value will be true
.
Getting the logger ready was the only setup code we needed for this service, so next we’ll send
another message to the service control manager telling it two things: the current state of the
service, and a list of commands the service will accept on the r
channel.
s <- svc.Status{
State: svc.Running,
Accepts: svc.AcceptStop | svc.AcceptShutdown,
}
When we first sent a message to s
, at the very start of the method, we didn’t set the
Accepts
field because the service wasn’t ready to accept any commands.
To keep the Execute
method short, I’ve moved the “main loop” of the service to a
separate function:
loop(r, s, logger)
Once loop
returns, the service prints one last log message and returns 0 to indicate
success:
logger.Print("service: shutting down")
return true, 0
The loop
function will do two things: it prints a log message every 30 seconds, so we
can see the service at work, and it handles messages from the service control manager. It’ll do
those things in a loop until the service is ready to shut down.
We’ll use time.Tick
for the “every 30
seconds” bit, and a select
statement to determine what to do next:
func loop(r <-chan svc.ChangeRequest, s chan<- svc.Status, logger *log.Logger) {
tick := time.Tick(30 * time.Second)
logger.Print("service: up and running")
for {
select {
case <-tick:
work(logger)
case c := <-r:
switch c.Cmd {
case svc.Interrogate:
s <- c.CurrentStatus
case svc.Stop:
logger.Print("service: got stop signal, exiting")
return
case svc.Shutdown:
logger.Print("service: got shutdown signal, exiting")
return
}
}
}
}
One thing you might notice is we’re handling the Interrogate
command even though we
didn’t include it in the Accepts
field before. The documentation says “Interrogate is
always accepted,” although it doesn’t say why and when I tried using the service without the
Interrogate
case I didn’t see any problems. But it’s probably safer to have it.
Finally, the work
function prints that log message:
func work(logger *log.Logger) {
logger.Print("service says: hello")
}
We build the service in the usual way with go build
:
GOOS=windows GOARCH=amd64 go build -o service.exe cmd/service/main.go
If you’re building it on Windows you won’t need to set GOOS
and GOARCH
--
I’m building it on an ARM MacBook so I need both. I assume you can also build the service for
Windows on ARM with GOARCH=arm
, but I haven’t tested that. (Maybe I should sign up for
one of the new
Azure ARM-based VMs
and try it out...)
Once we have the service.exe
ready, the easiest way to install it is on the command
line. Open the Windows Command Prompt as Administrator and run
sc create sample-service start=auto binPath=C:\sample-service\service.exe
With start=auto
, the service will be started when Windows starts, but it’s not running
yet. We can either start it in the Services GUI or on the command-line with
sc start sample-service
As expected, the service now shows up in the Services tool:
and it does its “work” of writing log messages:
To remove the service, we first stop it:
sc stop sample-service
and check the service log to verify that it handled the stop signal correctly:
Then we can delete the service:
sc delete sample-service
Wouldn’t it be nice if we had an installer for the service so we won’t have to remember that
sc
command-line anymore?
Part 2 of this series
is going to show how to write a simple Go program that can install or uninstall a Windows service.