4
\$\begingroup\$

I am a beginner at using Golang, I would like advice about the following program. It is a CLI tool that can check the expiration dates of HTTPS certificates concurrently. I have only used the standard library and would appreciate any feedback that I can learn from.

package main

import (
    "context"
    "crypto/tls"
    "encoding/json"
    "errors"
    "flag"
    "fmt"
    "log"
    "net"
    "os"
    "runtime"
    "sort"
    "strconv"
    "strings"
    "sync"
    "time"
)

const programVersion = "1.0.0"

func printHelp() {
    fmt.Printf("Usage: %s [options] url\n\n", os.Args[0])
    fmt.Println("This application determines when the SSL certificate will expire for the provided url.")
    fmt.Println()
    fmt.Println("Notes:")
    fmt.Println("- File extension arguments should be provided without a protocol (e.g., \"example.com\").")
    fmt.Println("- This application assumes that the the TLS connection should be made over port 443 unless a port is already provided in the URL (e.g., \"smtp.example.com:465\").")
    fmt.Println("- This application does not attempt to modify the provided URL.")
    fmt.Println()
    fmt.Println("Program options:")
    flag.PrintDefaults()
}

type JsonSuccessResponse struct {
    Url       string    `json:"url"`
    NotAfter  time.Time `json:"notAfter"`
    NotBefore time.Time `json:"notBefore"`
}

type JsonFailureResponse struct {
    Url   string `json:"url"`
    Error string `json:"error"`
}

type JsonResponse struct {
    Data   []JsonSuccessResponse `json:"data"`
    Errors []JsonFailureResponse `json:"errors"`
}

func buildJsonReponse(data chan JsonSuccessResponse, failures chan JsonFailureResponse) JsonResponse {
    toReturn := JsonResponse{}
    for jsonSuccessResponse := range data {
        toReturn.Data = append(toReturn.Data, jsonSuccessResponse)
    }
    sort.SliceStable(toReturn.Data, func(i, j int) bool { return toReturn.Data[i].Url < toReturn.Data[j].Url })
    sort.SliceStable(toReturn.Data, func(i, j int) bool { return toReturn.Data[i].NotAfter.Before(toReturn.Data[j].NotAfter) })
    for jsonFailureResponse := range failures {
        toReturn.Errors = append(toReturn.Errors, jsonFailureResponse)
    }
    sort.SliceStable(toReturn.Errors, func(i, j int) bool { return toReturn.Errors[i].Url < toReturn.Errors[j].Url })
    return toReturn
}

func outputResult(jsonResponse JsonResponse, outputToJsonP bool) error {
    if outputToJsonP {
        mJson, err := json.Marshal(jsonResponse)
        if err != nil {
            return err
        }
        fmt.Println(string(mJson))
    } else {
        now := time.Now()

        for _, jsonSuccessResponse := range jsonResponse.Data {
            fmt.Println("Checking", jsonSuccessResponse.Url, "cert expiration dates against:", now)

            startInHours := int64(now.Sub(jsonSuccessResponse.NotBefore).Hours())
            if startInHours < 0 {
                fmt.Println("Not valid until ", jsonSuccessResponse.NotBefore)
            }

            expiryInHours := int64(jsonSuccessResponse.NotAfter.Sub(now).Hours())
            if expiryInHours <= 0 {
                fmt.Println("Expired on ", jsonSuccessResponse.NotAfter)
            } else {
                if expiryInHours < 24 {
                    fmt.Println("\tWill expire on", jsonSuccessResponse.NotAfter, "which is in", expiryInHours, "hours.")
                } else {
                    fmt.Println("\tWill expire on", jsonSuccessResponse.NotAfter, "which is in", expiryInHours/24, "days and", expiryInHours%24, "hours.")
                }
            }
        }

        for _, jsonFailureResponse := range jsonResponse.Errors {
            fmt.Println("Failed to check HTTPS certification for", jsonFailureResponse.Url, "with error:", jsonFailureResponse.Error)
        }
    }
    return nil
}

func checkHttpsCertificate(ctxt context.Context, url string, timeoutInSeconds uint64, verifySsl bool) (JsonSuccessResponse, error) {
    var toReturn JsonSuccessResponse
    if !strings.ContainsAny(url, ":") {
        url = url + ":443"
    }
    toReturn.Url = url

    dialer := tls.Dialer{
        Config: &tls.Config{InsecureSkipVerify: !verifySsl},
    }
    connection, err := dialer.DialContext(ctxt, "tcp", url)
    if err != nil {
        if terr, ok := err.(net.Error); ok && terr.Timeout() {
            return JsonSuccessResponse{}, errors.New("I/O Timeout (DF95AE47-F677-40D0-B1F3-209DA7266AAC). Failed to connect to " + url + " in " + strconv.FormatUint(timeoutInSeconds, 10) + " seconds.")
        } else {
            return JsonSuccessResponse{}, fmt.Errorf("SSL certificate err (%w): "+err.Error(), err)
        }

    }
    defer connection.Close()

    tlsConnection := connection.(*tls.Conn)
    connectionState := tlsConnection.ConnectionState

    if len(connectionState().PeerCertificates) > 0 {
        // Relies on ordering: "The first element is the leaf certificate that the connection is verified against."
        firstCertificate := connectionState().PeerCertificates[0]

        toReturn.NotAfter = firstCertificate.NotAfter
        toReturn.NotBefore = firstCertificate.NotBefore
        return toReturn, nil
    } else {
        return JsonSuccessResponse{}, errors.New("Encountered HTTPS certificate with no peer certificates (internal invariant broken: a9f6135d-93fc-4319-891d-c67bf5a149fc) for URL: " + url)
    }
}

func processUrlsParallel(ctxt context.Context, urls []string, timeoutInSeconds uint64, verifySsl bool) (chan JsonSuccessResponse, chan JsonFailureResponse) {
    wg := &sync.WaitGroup{}
    data := make(chan JsonSuccessResponse, len(urls))
    failures := make(chan JsonFailureResponse, len(urls))

    for _, url := range flag.Args() {
        wg.Add(1)

        go func(ctxt context.Context, url string, w *sync.WaitGroup, data chan JsonSuccessResponse, failures chan JsonFailureResponse, timeoutInSeconds uint64, verifySsl bool) {
            defer w.Done()

            jsonResponse, err := checkHttpsCertificate(ctxt, url, timeoutInSeconds, verifySsl)

            if err != nil {
                failures <- JsonFailureResponse{
                    Url:   url,
                    Error: err.Error(),
                }
            } else {
                data <- jsonResponse
            }
        }(ctxt, url, wg, data, failures, timeoutInSeconds, verifySsl)
    }

    wg.Wait()
    close(data)
    close(failures)

    return data, failures
}

func main() {
    var (
        helpNeeded, outputJson, showVersion, verifySsl bool
        timeoutInSeconds                               uint64
    )

    flag.BoolVar(&outputJson, "json", false, "Output json to STDOUT")
    flag.BoolVar(&helpNeeded, "h", false, "Show help message")
    flag.BoolVar(&showVersion, "v", false, "Show program version")
    flag.BoolVar(&verifySsl, "verify-ssl", false, "Verify SSL certificate")
    flag.Uint64Var(&timeoutInSeconds, "timeout", 5, "Timeout in seconds")

    flag.Parse()

    maxThreads, convErr := strconv.ParseInt(os.Getenv("MAX_THREADS"), 10, 32)
    if int(maxThreads) > 0 && convErr == nil {
        runtime.GOMAXPROCS(int(maxThreads))
    }

    if helpNeeded {
        printHelp()
        return
    }

    if showVersion {
        fmt.Println(programVersion)
        return
    }

    if timeoutInSeconds < 1 {
        log.Fatal("Provided timeout must be greater than zero.")
    }

    if len(flag.Args()) < 1 {
        printHelp()
        log.Fatal("Please provide argument for url.")
    }

    ctxt, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutInSeconds)*time.Second)
    data, failures := processUrlsParallel(ctxt, flag.Args(), timeoutInSeconds, verifySsl)
    cancel() // Cancel context as soon as processUrlsParallel returns

    jsonResponse := buildJsonReponse(data, failures)
    err := outputResult(jsonResponse, outputJson)
    if err != nil {
        log.Fatal("Internal Error (GUID: e6ad5934-0a7d-4100-9889-7274058e60db): Failed to marshall internal structure to JSON")
    }
}
\$\endgroup\$
1
  • \$\begingroup\$ I have updated the code for additional review in a new question \$\endgroup\$
    – preetpalS
    Nov 27, 2022 at 7:49

1 Answer 1

3
\$\begingroup\$

At first glance

Having just skimmed some of the code you posted, a couple of things jumped out right away:

  • printHelp: If I were to provide a description of what my tool does, I'd call the flag.Usage function, and I'd certainly avoid multiple calls to fmt.Println or fmt.Printf. Golang has multi-line strings (using backtick as delimiter)
  • runtime.GOMAXPROCS(int(maxThreads)) is something that is fairly common in code written by people relatively new to golang. There seems to be some uncertainty/confusion as to what go routines actually are, and how much of a say you get in whether a routine spawns a thread or not. The long and short of it is that you don't get too much of a say. GOMAXPROCS can still result in more threads being spawned (due to syscalls). It's useful if you want to run a process in the background without hogging too many resources, but for CLI tools that you run and wait for a result, there's really little to no point to doing this. The go runtime isn't perfect, but it handles concurrency very well, and yes, that's concurrency: not all routine is limited to its own thread, so setting GOMAXPROCS will not limit the number of routines that are concurrently running anyway.
  • Coding standards are important. Back in the early days a lot of people criticised golang for being "too opinionated" (the whole gofmt enforcing tabs, K&R style brackets etc...). This has proven to be a great thing, increasing readability and collaboration across the ecosystem. Aside from the gofmt stuff, it's therefore strongly advised to stick to the Go codereview comments. Most notably: initialisms (which I see scattered throughout your code) should be capitalised accordingly: Not Url or Json, but rather URL and JSON. You can see this in golang's own standard library in places like net/http with functions like ListenAndServeTLS
  • There are more substantial issues which I'll cover next, most notably in the rather unfortunately named processUrlsParallel. Unfortunate because Parallel should be concurrent, and the initialism should be URLs. The bigger issue there though, is what we'll tackle now

Use of buffered channels

The processUrlsParallel function uses buffered channels. That's great. buffered channels are awesome, and very useful. However, you're resorting to using them because, from the callers' point of view, processUrlsParallel is not a parallel call, but rather a blocking call. You're returning channels, but by the time the caller can set to work with them, they've been filled to the brim already. Why bother? Why not return 2 slices instead? Functionally, they are interchangeable in your case. Using channels here is perfectly valid, but I'd change a couple of things:

  1. I'd return directional channels, in this case I'd return channels that can only be used to read from. This serves to write self-documenting code, and in larger projects avoids silly bugs caused by someone mistakenly writing some value onto the wrong channel.

  2. The URL's are CLI args, so you can pass them in as a slice, but if you were to read them from a file, you might want to pass them through a channel of their own. This will allow you to start processing the URL's while reading them. The function would then look something like this:

    func processURLs(ctx context.Context, uCh <-chan string, timeout time.Duration, verifySSL bool) (<-chan JSONSuccess, <-chan JSONErr)
    

Now you'll notice I've shortened the variable names. This is common in go code. I personally like it this way, because it becomes a barrier that makes it harder to write incredibly long and verbose functions. It keeps my code tidier. The timeout type likewise communicates to the caller, and more importantly reader/maintainer what this value is meant to express. The channels are all directional, which immediately tells me this function will read data from the uCh channel, and return 2 channels that I'm expected to read from. Now because I'm passing in a channel, it's impossible to do the same thing you are doing (which is to use a waitgroup, fill the channel buffers and close the channels). I have no way to know how many URL's I'm expected to process. Not to worry, we have a context, and we know we're done once our uCh is closed and empty. So let's implement that:

 func processURLs(ctx context.Context, uCh <-chan string, timeout time.Duration, verifySSL bool) (<-chan JSONSuccess, <-chan JSONErr) {
    sCh, eCh := make(chan JSONSuccess, 10), make(chan JSONErr) // make channels with some buffer
    go func() {
        defer func() {
            // cleanup when this routine exists
            close(sCh)
            close(eCh)
        }()
        // read the URL's. This loop will break once the channel is both empty and closed
        for URL := range uCh {
            select {
            case <-ctx.Done():
                return // the context was cancelled, indicating we're shutting down
            default:
                // we have a URL, check it
                resp, err := checkHTTPSCert(context.WithTimeout(ctx, timeout), URL, verifySSL)
                // depending on the result, write to the appropriate channel
                if err != nil {
                    eCh <- err
                } else {
                    sCh <- resp
                }
            }
        }
    }()
    return sCh, eCh // return channels right away
}

That's it. Nice and easy. To use a slice, you can just replace the argument, and the loop in the routine, but the rest works all the same. If you want to support both, you can just create a helper function that makes a channel, and pushes the slice on there.

As far as the function itself goes, I suppose the biggest difference is here:

checkHTTPSCert(context.WithTimeout(ctx, timeout), URL, verifySSL)

Rather than passing through the timeout value, I'm providing a context, wrapped around the context that this function is using, and configure it to have a timeout. When using the net/http package (and indeed many other packages that handle connections), a cancelled context (or a timed-out one) will result in pending requests/transactions getting cancelled. We don't really have to worry about that anymore. What's more: these contexts are wrapped. If the caller (in your case the main function) passes in a context that will be cancelled in case the application received a KILL/TERM signal, that context cancellation is propagated automatically. ongoing requests, routines, etc... can all be notified and gracefully return. For a CLI tool like this, it's not a massive deal, but again: for larger projects, this quickly becomes a necessity. Moving on to your checkHttpsCertificate function (which receives but seemingly doesn't use the timeout argument!):

Other issues

The bulk of this function could be rewritten to be a bit nicer, and I might go through in a bit more detail should I find some more spare time, but for now I'll point to this particular function to point some gripes I have looking at your code. Some of these issues are found throughout your code, like not retunring early or having pointless else clauses:

return early - only if, no else

This function in particular is rather smelly, the way it ends with:

if len(connectionState().PeerCertificates) > 0 {
    // Relies on ordering: "The first element is the leaf certificate that the connection is verified against."
    firstCertificate := connectionState().PeerCertificates[0]

    toReturn.NotAfter = firstCertificate.NotAfter
    toReturn.NotBefore = firstCertificate.NotBefore
    return toReturn, nil
} else {
    return JsonSuccessResponse{}, errors.New("Encountered HTTPS certificate with no peer certificates (internal invariant broken: a9f6135d-93fc-4319-891d-c67bf5a149fc) for URL: " + url)
}

The first if ends in a return, why have that else there? It's completely pointless!

if len(connectionState().PeerCertificates) > 0 {
    // do stuff
    return toReturn, nil
}
return JsonSuccessResponse{}, errors.New("Encountered HTTPS certificate with no peer certificates (internal invariant broken: a9f6135d-93fc-4319-891d-c67bf5a149fc) for URL: " + url)

Likewise, this entire function keeps calling connectionState() over and over again. Just assign the return value to a variable, avoid redundant calls like this. So instead of:

connectionState := tlsConnection.ConnectionState

just write

cState := tlsConnection.ConnectionState()
if len(cState.PeerCertificates) > 0 {
}

Further up in this same function, you have a similar bit of code smell in this bit:

if err != nil {
    if terr, ok := err.(net.Error); ok && terr.Timeout() {
        return JsonSuccessResponse{}, errors.New("I/O Timeout (DF95AE47-F677-40D0-B1F3-209DA7266AAC). Failed to connect to " + url + " in " + strconv.FormatUint(timeoutInSeconds, 10) + " seconds.")
    } else {
        return JsonSuccessResponse{}, fmt.Errorf("SSL certificate err (%w): "+err.Error(), err)
    }

}

Again, the else is completely unnecessary. I also fail to see the point in wrapping an error, and at the same time concatenating its string representation to the format string. What if the error string contains %s, for example. This is not only pointless (errors can be unwrapped after all), it's also fragile. The first error here is a nightmare of concatenation, with manual formatting for things that you can have golang format for you by just using the correct type (time.Duration).

Additionally, golang errors should start with a lower-case character as per convention. Most linters will complain about this code. With that said, let's rewrite both errors:

errors.New("I/O Timeout (DF95AE47-F677-40D0-B1F3-209DA7266AAC). Failed to connect to " + url + " in " + strconv.FormatUint(timeoutInSeconds, 10) + " seconds.")
// becomes
errors.New("connection to URL %s failed (timeout limit %s)", URL, timeout) // assumes timeout is of time time.Duration, will print 10s for 10 seconds...

fmt.Errorf("SSL certificate err (%w): "+err.Error(), err)
// becomes either one of:
fmt.Errorf("certificate error (%w): ", err)
fmt.Errorf("certificate error: %s", err) // not wrapped, but err.Error() gets added

Force error checks

Now this brings me to the biggest issue I have with this function. People often complain how go forces you to check errors as return values, which leads to more verbose code. There's something to be said for this, for sure, but the way you're communicating errors is the worst of both worlds. Because, even in error cases, you're returning a valid JSONSuccessResponse object, it's very easy to picture a scenario where someone writes:

resp, _ := checkSSLCert(...)
// or even
resp := checkSSLCert(...)

Whether this call is successful or not, the resp variable will be set. Compare this to a version of this function which returns a pointer to JSONSuccess on success, and nil otherwise. All of your return statements look nicer:

return nil, errors.New("connection to URL %s failed (timeout limit %s)", URL, timeout)

And the caller is forced to check the error. Failing to do so will cause their code to crash on error:

resp, _ := checkSSLCert(...) // fails
resp.AccessField // runtime panic, resp is nil

URGENT CHANGE

Just because I've scrolled through your code a few times, and each time felt like somewhere, somehow, a unicorn was dying, please, please, please fix outputResult. Change its current:

if outputToJsonP {
    mJson, err := json.Marshal(jsonResponse)
    if err != nil {
        return err
    }
    fmt.Println(string(mJson))
} else {

To remove that bloody else that wraps the entire function. Simply write

if outputToJsonP {
    mJson, err := json.Marshal(jsonResponse)
    if err != nil {
        return err
    }
    fmt.Println(string(mJson))
    // return early HERE
    return nil
}
// rest of the function here
return nil

Right, I though this was going to be a short review, I digressed quite a bit, but I do hope that, despite my just typing out what essentially is a stream of consciousness, you gain something from this.

I am aware that I can sometimes come across as harsh/blunt. Just keep in mind that none of this criticism is in any way meant as a personal attack, or aimed to be discourage you. I've always felt that I learned more/faster after a particularly brutally honest review of my work.

Replies to comments

So you've raised a couple more questions in comments. I'll add the answers here for clarity and completeness.

First up: does the channel buffer in my example limit the number of routines? The short answer is no. Earlier on, I included an example of the processURLs function with channels buffered to a size of 10. The being made here is that connecting to, and checking the certs will take longer than pushing the result on the channel, and the routine consuming said channel data will do so faster than the requests themselves (otherwise, using routines in the way you are doing doesn't make all that much sense). In my example, I'm checking all URL's in a single routine. I didn't explicitly say otherwise, but in reality, each URL would be checked in its own routine. I'll rewrite the processURLs function here, this time adding the "1 routine per URL" bit. In doing so, I'll also give an example of when you should pass an argument to an anonymous function:

 func processURLs(ctx context.Context, uCh <-chan string, timeout time.Duration, verifySSL bool) (<-chan JSONSuccess, <-chan JSONErr) {
    sCh, eCh := make(chan JSONSuccess, 10), make(chan JSONErr) // make channels with some buffer
    wg := sync.WaitGroup{} // create a waitgroup
    go func() {
        defer func() {
            wg.Wait() // now add the waitgroup so as to not close the channels too soon
            // cleanup when this routine exists
            close(sCh)
            close(eCh)
        }()
        // read the URL's. This loop will break once the channel is both empty and closed
        for URL := range uCh {
            wg.Add(1) // we're adding a new routine
            // start the routine for this particular URL
            go func(URL string) { // takes the current value as an argument
                defer wg.Done() // decrease waitgroup
                select {
                case <-ctx.Done():
                    return // the context was cancelled, indicating we're shutting down
                default:
                    // we have a URL, check it
                    resp, err := checkHTTPSCert(context.WithTimeout(ctx, timeout), URL, verifySSL)
                    // depending on the result, write to the appropriate channel
                    if err != nil {
                        eCh <- err
                    } else {
                        sCh <- resp
                    }
                }
            }
        }(URL) // pass in URL value
    }()
    return sCh, eCh // return channels right away
}

So we've added a waitgroup. Not to have this function wait for all the work to be done, but more because we need to make sure that we don't close the channels before all routines have returned (avoiding writes to a closed channel).

You can also see that the URL variable is passed in as an argument to our inner routine. If we didn't do this, the routine would just reference the outer URL variable, which could get reassigned before the routine gets created/executed. We need to provide the inner routine with the specific value for URL we want to use. If we didn't do this, we could end up in situations that -in pseudo code- boil down to something like this:

v := <-ch
go func() { fmt.Println(v) }()
v = <-ch // update v
<start the previously scheduled routine, which will now print out the new value of v

By masking the outer variable (URL or in the pseudo-code v), each routine has its own variable masking the outer one, set to the specific value at the point the routine was declared:

v := <-ch
go func(v1 any) { fmt.Println(v1) }(v)
v = <-ch // update v
go func(v2 any) { fmt.Println(v2) }(v)

Now we'll get the expected output. It's still possible to get the output of v2 first, and then v1, but both will be printed regardless.

Other, more niche, situations where you may want to pass in variables to pass in variables as arguments is when you're masking imports. 99% of times, this is the result of unfortunate package/variable naming, but it can sometimes be used as a safeguard, preventing you from making certain calls in the wrong place. This can be used in very rare (agian: 99% of the times, this is code smell), highly concurrent bits of code:

package foo

import (
    // a bunch of packages|
    "my.complex.mod/concurrent/niche"
    "my.complex.mod/concurrent/danger"
)

Say the niche and danger packages should only be called in very, very special situations, in particular our danger module could contain some CGO stuff, or make use of reflection and unsafe stuff. We need to provide it with some values to work on, but we have to ensure that this only happens in one place in the code. We can protect ourselves from accidentally using these packages in the wrong place like this:

func DoStuff(niche args.Niche, danger args.Danger) {
    // this function can't call function on niche and danger imports, because the variables mask the package name
    // niche.SomeFuncFromPackage will look for a method on args.Niche. Same for danger.Foo()
    // these variable names mask the import, but...

    // some code
    // we have determined we need the niche/danger packages:
    ch := make(chan args.DangerRet) // unbuffered
    defer close(ch)
    go func(narg args.Niche, darg args.Danger) {
        nRet := niche.ProcessArgs(nargs)
        ch <- danger.DoStuff(darg, nRet) // call danger package and push to channel
    }(niche, danger)
    return <- ch // wait for routine and return
}

Situations like these are extremely rare, but sometimes you want to make sure some object isn't being used, not even read access, in any other routine (of course, you'd have to implement a bunch of other code for that), so you can then use it in a very specific way that goes beyond the normal framework the go runtime provides for safe, concurrent code. I am still using a fairly simple setup, because the point here is not the use of a go routine. I'm leveraging the routine mostly to ensure that I'm doing everything in as contained a way as possible. The overall function is still blocking, because I am using an unbuffered channel, so the routine and the return statement sync up. You could just use an anonymous function here, but considering that this is, as I said repeatedly, a very rare thing to do, chances are you'll still use a routine that will first do everything it needs to do to ensure the values you're about to operate on are not being used anywhere else, etc...

\$\endgroup\$
11
  • \$\begingroup\$ Honestly, this feedback is very valuable. I really appreciate it (it's going to take me a while to go through it all in-depth). Honestly, I should have seen the control flow issues (unnecessary if/else, not using an early returns) prior to posting this code. I am going to modify the code to use directional channels. \$\endgroup\$
    – preetpalS
    Nov 17, 2022 at 23:18
  • \$\begingroup\$ I have some questions: \$\endgroup\$
    – preetpalS
    Nov 17, 2022 at 23:19
  • 1
    \$\begingroup\$ As for the returns: nil, someErr is kind of the norm, akin to option/match constructs. The buffer sizes of 10 limits the number of routines in the sense that it's a cap on the backlog that is allowed to build up. You can have no more than 10 routines waiting to be able to write to these channels. The processURLs call is not blocking, so the caller is expected to immediately start receiving data from these channels, freeing up space for more routines, so you could have, say, 100 routines actually checking the certificates, 10 can instantly write success (+ 10 error)... \$\endgroup\$ Nov 18, 2022 at 10:13
  • 1
    \$\begingroup\$ The rest would be waiting for the routine that reads these channels before it can write its result onto the channel. In the snippets I provided, the writes on these channels happen in a single routine, but in reality you'd check the certs in 1 routine per URL. TL;DR the buffer of 10 still allows for more routines to be spawned, it only "limits" how many of them can terminate at any given time. As long as reading from the channels is faster than populating the channel, though, a buffer of 10 is unlikely to be a bottleneck \$\endgroup\$ Nov 18, 2022 at 10:16
  • 1
    \$\begingroup\$ as for functions (anonymous functions and scope), the rule of thumb here would be that masking variables or packages from a higher scope would prompt you to pass the variables in as arguments, as well as using routines, or functions to call later on which use loop vars have to be passed in as arguments \$\endgroup\$ Nov 18, 2022 at 10:19

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.