Skip to content

posts🔗

Go R1 Day 79

progress

Felt like revisiting Exercism since the great updates and wanting a break from the more deep concept dives on mechnical sympathy and optimization in the Ultimate Go course (which is stellar).

Completed the following to continue on syllabus. It's taking me back to basics, but stuff I can knock out pretty quick.

From this I determined that I'm great at writing chained boolean statements.

if goodAtBool && coderIsMe {
    fmt.Println("I'm a good coder")
} else if goodAtBool && !coderIsMe {
    fmt.Println("I'm a good coder")
} else if !goodAtBool && coderIsMe {
    fmt.Println("I'm a bad coder")
} else {
    fmt.Println("I'm a bad coder")
}

Thank you GitHub Copilot for the codeblock above. I claim no responsibility to the results of what AI generated madness was created.

Go R1 Day 78

progress

Ultimate Go: 2.3.4 - Pointers-Part 4 (Stack Growth)

Scenario:

  • 2k stack for each goroutine.
  • 50,000 goroutines.
  • Eventually, you'll want to make the function call and you'll want to grow the stack if the current stack limit is hit.
  • We'll want to use contigous stacks.
  • The new stack will be a new contigous allocated block of memory.
  • The stack growth requires all the prior values to be moved over to the new doubled stack.

A goroutine can only share values from the heap. This prevents the issues occuring from shared values in different stacks.

Ultimate Go: 2.3.5 - Pointers-Part 5 (GC)

Mark and sweep collector.

We don't need to worry about the implementation.

However, this topic is useful to ensure we write code that is "sympathetic" to the GC.

At this point, I opted to come back to GC details and focus on some testing and package design principles.

Go R1 Day 77

progress

More Fun With Golangci-lint

Been doing a ton the last month with golangci-lint tooling. I think this has been one of the most educational tools for learning Go I've come across. It has forced me to evaluate why issues are flagged, if they are opinionated preferences or best practices.

For example, wsl ensures that statements are not cuddled. This follows Dave Cheney's writing about having an empty line seperate phrases of thought.

It's a bit annoying to implement as a linter though, and can't be applied programaticaly so I'm not using that.

Linting - Shadowing Package Names

Another one that I caught from Goland linting today, that golangci-lint didn't seem to catch, was the shadowing of a package name.

In this scenario I found code where:

package taco


func main() {
 taco := taco.Method()
}

While this is legal, it's a confusing practice, and thereafter prohibits the usage of the taco package as it's been overshadowed by the variable.

To me this is a clear violation of Go's preference for "no magic" and readability.

In this scenario, the fix is simple. Change the variable name used or alias the package (my preference).

package (
  pkgtaco "taco"
)


func main() {
 taco := pkgtaco.Method()
}

Linting - Handling Errors

Also did some investigation on errcheck and flagging of handling file close and response body closing for http. This is one of those areas that linters flag and it's a "it depends" and not very consistent.

Basically the gist is ignore, except if file writing is occuring then it's probably needing an explicit handle.

Go R1 Day 76

progress

Lots of more technical detail on Memory & Data Semantics Section.

I value this as my background in databases means that things like Stack/Heap weren't part of my education as much. This has been useful as there are new concepts, paradigms, and ways of thinking and it's providing a lot more foundational concepts for me to better understanding the system and compiler behavior in Go.

Ultimate Go: Pointers-Part-3: Escape Analysis

Notes
  • Stacks are self-cleaning
  • Compiler knows what goes on stack and what goes on heap at compile time. Knowing where the value is constructed is part of the static analysis the compiler performs.
  • Bill said this is a really powerful feature that will probably impact future languages.
  • When you see a pointer in the return value, this could be phrased "shared up the callstack".
  • This means that the construction inside the function would be on the heap not the stack.
  • If you are trying to access items further up on the stack, it requires pointers.
  • Bill said what's cool about this is the ability to access an item on the heap via this pointer, as if we were working with pointers to a stack value. It abstracts the machine level details away so we benefit from easy out of frame access to values with pointers.
  • We don't have to worry about where the location of the value being constructed is. Due to escape analysis, the compiler will determine the best place, but the reality is some items have to go to the heap, and the compiler is smart enough to help determine this.
  • Why does this matter? Bill says that if performance matters, understanding the concepts matter because garbage collection and other latencies are impacted by these concepts.
  • Can view escape analysis from compiler with: go build -gcflags -m=2.
    • I tested this on one of my projects, and found that function complexity can prevent inlining, lots of escape to heap references and more.
    • While not needed for basic build automation type helpers, I could see the value in scalable design for Go in examining this in more details. It's almost like using SQL Server execution plans to optimize for higher performance.
Takeaways
Don't hide the cost

If we are doing construction to a variable, we use value construction. Avoid pointer semantic construction if not in the return.

Example:

// clear visible cost of the allocation by value construction and passing of pointer back up the call stack
func createSomething() *something {
  u := something{
    name: "example",
  }
  return &u // <--- This makes clear the cost and allocation back up the callstack.
}
// cost is obscured by construction being a pointer
// and returning a value that is not clear to reader if value or pointer
func createSomething()*something {
  u := &something{
    name: "example",
  }
  return u // <--- Not good. Hides the cost, and require reading function further to find that this is a pointer.
}

Making cost obvious and visible is a big priority for readable maintainable code with a team.

Go R1 Day 74

progress

  • Worked with DynamoDB schema (NoSQL Document database).
  • Invoked local lambda function using Docker and also remove invocation with serverless invoke. This took a bit of work to figure out as the parameters weren't quite clear. Upon using --path for the json template I got from AWS Lambda console, I was able to to get to invoke, and stream the logs with --log.
  • More mage magic with promptui and other features, so I can now test a full tear down, build, publish, and invoke a test selection by running: mage sls:destroy build sls:deploy sls:test remote.

Go R1 Day 73

progress

  • I'm more on day 90+ but haven't made the time to log it.
  • Been working a ton with Mage and have some really cool work I'll be writing up soon, including using for health checks on projects, setup of core tooling, encoding credentials, and more. 💯
  • Also been working heavily on Go mono-repo module structure and some serverless based architecture tests that will make a fun write-up. I'm thinking of a demo of "Disappointment as a Service" with a lambda driven api for tracking disappointing events and returning something to help bring reality-checks to us all. 😆

  • I used a snippet from a blog today that allowed buffering stdout from go commands to capture and avoid streaming each line by wrapping up in a pterm spinner component. Pretty neat!

Go R1 Day 72

progress

  • Learned more on "mechanical sympathy" related to Go and memory management.
  • How the Go compiler works related to frames with memory allocation.
  • Built Mage tasks for licensing and validating problematic licenses aren't included in a project.

Go R1 Day 70

progress

  • Concurrency section wrap-up with Learn Go With Tests.
  • Reviewed material learned from: Go R1 Day 61
  • Read material, but didn't do a lot of tests on this, per mostly concept oriented. Used concurrent progressbar example from uiprogress project to test concurrent UI updates.
  • My last concurrency test case was to launch many concurrent processes for a load test. This didn't leverage goroutinues as typically used, since it was calling to an executable on the host machine. However, this provided a great use case for something I've done before with DevOps oriented work and showed how to use concurrency as a blocking operation. Once the user was done with the test, ctrl+c was used to kill the active requests and the program exited.
  • I need more practice with channels. I was only wanting error stdout content, and so didn't have any need for receiving channel output back in a structured way. This is probably an atypical usage of concurrency, fitting for an external load test, but not internal Go code.
  • Still found it pretty cool that I could spin up 500 processes at once, with far less overhead than doing in PowerShell.

{{< admonition type="Note" title="Example Of Doing In PowerShell" open=true >}} Doing this in PowerShell is far more compact, but not as performant.

This is a good example of the different in using Go for adhoc tasks. It will require more code, error handling care, but pays off in something that is likely more stable and easier to run across multiple systems with a single binary.

#!/usr/bin/env pwsh
$Server = 'IPADDRESS'
$ServerPort = '3000'
Write-Host 'Load Test Start'
$RandomPort = '4000'

$j = @(4000..4100)| ForEach-Object {
    $c = $_
    Start-ThreadJob -ThrottleLimit 1000 -StreamingHost $Host -InputObject $c -ScriptBlock {
    $RandomPort = $input
    &mybinary serve --max-retry-count 5 --header "user-id: $(petname)" --header "session-id: $(uuidgen)" "${using:Server}:${using:ServerPort}"
}
}
$j | Wait-Job | Receive-Job
$j | Stop-Job

I didn't benchmark total load difference between this and Go, but I'm sure the pwsh threads were a bit more costly, though for this test case might not have been a large enough value to make much difference.

{{< /admonition >}}

Code Examples

This first section is the startup. Key points:

  • main() is the entry point for the program, but doesn't contain the main logic flow. Inspired by Matt Ryer's posts, I now try to ensure main is as minimal as possible to encourage easier automation in testing. Since run contains the main logic flow, the actual CLI itself can be called via integration test by flipping to Run() and calling from testing file using a blackbox testing approach.
package main

import (
    "bytes"
    "errors"
    "flag"
    "fmt"
    "io"
    "math"
    "os"
    "os/exec"
    "strings"
    "sync"
    "time"

    shellescape "github.com/alessio/shellescape"
    petname "github.com/dustinkirkland/golang-petname"
    "github.com/google/uuid"
    "github.com/pterm/pterm"
    "github.com/rs/zerolog"
    "github.com/rs/zerolog/log"
)

const (
    // exitFail is the exit code if the program
    // fails.
    exitFail = 1

    // desiredPort is the port that the app forwards traffic to.
    desiredPort = 22

    // petnameLength is the length of the petname in words to generate.
    petNameLength = 2

    // startingPort is the starting port for a new connection, and will increment up from there so each connection is unique.
    startingPort = 4000

    // maxRetryCount is the number of times to retry a connection.
    maxRetryCount = 5
)

func main() {
    if err := run(os.Args, os.Stdout); err != nil {
        fmt.Fprintf(os.Stderr, "%s\n", err)
        os.Exit(exitFail)
    }
}

Next run contains the main logic flow. The goal is that all main program logic for exiting and terminating is handled in this single location.

// Run handles the arguments being passed in from main, and allows us to run tests against the loading of the code much more easily than embedding all the startup logic in main().
// This is based on Matt Ryers post: https://pace.dev/blog/2020/02/12/why-you-shouldnt-use-func-main-in-golang-by-mat-ryer.html
func run(args []string, stdout io.Writer) error {
    if len(args) == 0 {
        return errors.New("no arguments")
    }
    InitLogger()
    zerolog.SetGlobalLevel(zerolog.InfoLevel)

    debug := flag.Bool("debug", false, "sets log level to debug")
    Count := flag.Int("count", 0, "number of processes to open")
    delaySec := flag.Int("delay", 0, "delay between process creation. Default is 0")
    batchSize := flag.Int("batch", 0, "number of processes to create in each batch. Default is 0 to create all at once")
    Server := flag.String("server", "", "server IP address")
    ServerPort := flag.Int("port", 3000, "server port") //nolint:gomnd

    flag.Parse()
    log.Logger.Info().Int("Count", *Count).
        Int("delaySec", *delaySec).
        Int("batchSize", *batchSize).
        Str("Server", *Server).
        Msg("input parsed")

    log.Logger.Info().
        Int("desiredPort", desiredPort).
        Int("petNameLength", petNameLength).
        Int("startingPort", startingPort).
        Msg("default constants")

    if *debug {
        zerolog.SetGlobalLevel(zerolog.DebugLevel)
    }

    RunTest(*Count, *delaySec, *batchSize, *Server, *ServerPort)
    return nil
}

Next, InitLogger is used to initialize the logger for zerolog. I don't need multiple configurations right now so this is just stdout.

// InitLogger sets up the logger magic
// By default this is only configured to do pretty console output.
// JSON structured logs are also possible, but not in my default template layout at this time.
func InitLogger() {
    output := zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339}
    log.Logger = log.With().Caller().Logger().Output(zerolog.ConsoleWriter{Out: os.Stderr})

    output.FormatLevel = func(i interface{}) string {
        return strings.ToUpper(fmt.Sprintf("| %-6s|", i))
    }
    output.FormatMessage = func(i interface{}) string {
        return fmt.Sprintf("%s", i)
    }
    output.FormatFieldName = func(i interface{}) string {
        return fmt.Sprintf("%s:", i)
    }
    output.FormatFieldValue = func(i interface{}) string {
        return strings.ToUpper(fmt.Sprintf("%s", i))
    }
    log.Info().Msg("logger initialized")
}

Test the existence of the binary being run in a load test, and exit if it doesn't exist. This should more likely be handled in the run fuction, but I just did it here for simplicity in this adhoc tool.

// TestBinaryExists checks to see if the binary is found in PATH and exits with failure if can't find it.
func TestBinaryExists(binary string) string {
    p, err := exec.LookPath(binary)
    if err != nil {
        log.Logger.Error().Err(err).Str("binary",binary).Msg("binary not found")
        os.Exit(exitFail)
    }

    return p
}

Next, buildCLIArgs handles the argument string slice construction. I learned from this to keep each line and argument independent as escaping has some strange behavior if you try to combine too much in a single statement, especially with spaces. Best practice is to keep this very simple.

// buildCliArgs is an example function of building arguments via string slices
func buildCliArgs(Server string, ServerPort int, port int) (command []string) {
    command = append(command, "server")
    command = append(command, "--header")
    command = append(command, fmt.Sprintf(`user-id: %s`, petname.Generate(petNameLength, "-")))
    command = append(command, "--header")
    command = append(command, fmt.Sprintf(`session-id: %s`, uuid.Must(uuid.NewRandom()).String()))
    command = append(command, "--max-retry-count", "5")
    command = append(command, Server+":"+fmt.Sprintf("%d", ServerPort))
    return command
}

Finally, a function that run the tests with some pretty output using pterm. This would be probably better to break-up for testing, but again, adhoc project, so this ended up working decently as I was learning concurrency.

// RunTest is the main test function that calculates the batch size and then launches the  creation using a routinue.
func RunTest(Count int, delaySec int, batchSize int, Server string, ServerPort int) {
    log.Logger.Info().Msg("RunTest startings")
    totalBatches := math.Ceil(float64(Count) / float64(batchSize))
    log.Logger.Info().Float64("totalBatches", totalBatches).Msg("batches to run")
    myBinary := TestBinaryExists("binaryname")
    port := startingPort
    var wg sync.WaitGroup

    totals := 0
    p, _ := pterm.DefaultProgressbar.WithTotal(Count).WithTitle("run s").Start()

    for i := 0; i < int(totalBatches); i++ {
        log.Debug().Int("i", i).Int("port", port).Msg("batch number")

        for j := 0; j < batchSize; j++ {
            if totals == Count {
                log.Debug().Msg("totals == Count, breaking out of loop")

                break
            }

            totals++
            log.Debug().Int("i", i).Int("", totals).Msg("")
            cmdargs := buildCliArgs(Server, ServerPort, port)
            wg.Add(1)
            go func() {
                defer wg.Done()
                buf := &bytes.Buffer{}
                cmd := exec.Command(, cmdargs...)
                cmd.Stdout = buf
                cmd.Stderr = buf
                if err := cmd.Run(); err != nil {
                    log.Logger.Error().Err(err).Bytes("output", buf.Bytes()).Msg(" failed")
                    os.Exit(exitFail)
                }
                log.Logger.Debug().Msgf(" %v", shellescape.QuoteCommand(cmdargs))
                log.Logger.Debug().Bytes("output", buf.Bytes()).Msg("")
            }()

            p.Title = "port: " + fmt.Sprintf("%d", port)
            p.Increment()
            port++
        }
        time.Sleep(time.Second * time.Duration(delaySec))
    }
    p.Title = "s finished"
    _, _ = p.Stop()
    wg.Wait()
}

Go R1 Day 71

progress

  • Learn Go With Tests -> Using select with channels to wait for multiple goroutines.
  • Of particular interest is this:

Notice how we have to use make when creating a channel; rather than say var ch chan struct{}. When you use var the variable will be initialised with the "zero" value of the type. So for string it is "", int it is 0, etc. For channels the zero value is nil and if you try and send to it with <- it will block forever because you cannot send to nil channels (go-fundamentals-select listed below)

  • Used httptest to create mock server for faster testing, and included wrapper around a calls to allow configuration for timeout. This ensures that testing can handle in milliseconds, but default behavior in a deployment would be 10 seconds or more.