2
\$\begingroup\$

I'm trying to write a simple program to find a certain file name within a directory tree. I use up to 30 coroutines.

Is there anything wrong with this code or what needs to be improved?

import (
    "fmt"
    "io/ioutil"
    "path/filepath"
    "sync"
)

const tofind = "select-test.go"

func main() {
    wg := sync.WaitGroup{}
    search("/Users/user/Documents", &wg)
    wg.Add(1)
    wg.Wait()
}

var permit = make(chan struct{}, 30)

func search(dir string, wg *sync.WaitGroup) {
    permit <- struct{}{}

    defer func() {
        <-permit
    }()
    defer func() {
        wg.Done()
    }()

    items, err := ioutil.ReadDir(dir)
    if err != nil {
        fmt.Println(err.Error())
    }
    for _, item := range items {
        if item.IsDir() {
            wg.Add(1)
            go search(filepath.Join(dir, item.Name()), wg)
        } else {
            if item.Name() == tofind {
                fmt.Println(filepath.Join(dir, item.Name()))
            }
        }
    }
}
\$\endgroup\$
5
  • 1
    \$\begingroup\$ Did you measure any benefit over the obvious single-threaded approach? Given that you're probably I/O bound, and good OSes are good at anticipating access patterns, I would expect diminishing returns well before the parallelism reaches 30 ways. \$\endgroup\$ Commented Jul 30, 2022 at 9:05
  • 1
    \$\begingroup\$ @TobySpeight: Rather than rely on expectations, I looked at actual numbers for 269,332 items in a home directory on an SSD drive. While there are diminishing returns, this is Go, so permitting up to 30 goroutines is fine. \$\endgroup\$
    – peterSO
    Commented Jul 30, 2022 at 13:26
  • \$\begingroup\$ In fact, I am asking this question more because I want to know if I am using the coroutine correctly...@TobySpeight \$\endgroup\$ Commented Jul 31, 2022 at 8:14
  • \$\begingroup\$ Thanks for measuring the performance - that's what I wanted to know! \$\endgroup\$ Commented Aug 1, 2022 at 10:58
  • \$\begingroup\$ @woxihuanxiayua Seeing as you're wanting to know whether your use of goroutines (not coroutines) is correct, I'm assuming that at some point you'd want to compare performance between using more/less routines, and how that'd affect overall performance. It's worth getting acquainted with the pprof tool for that \$\endgroup\$ Commented Aug 12, 2022 at 9:04

2 Answers 2

2
\$\begingroup\$

OK, there's a number of things I'd change about your code. I'll go through what you posted, making suggestions based on what you have already, then I'll make some additional suggestions.

const tofind = "select-test.go"

Just a small nit-pcik here: to improve readability, would suggest camel-casing constants like this. IMO, toFind is easier to read. In a small piece of code like this, it doesn't make that much of a difference, but as code bases grow, and the number of constants increases, it starts adding up.

func main() {
    wg := sync.WaitGroup{}
    search("/Users/user/Documents", &wg)
    wg.Add(1)
    wg.Wait()
}

Seeing the call to search, passing it a pointer to the wait group, and only incrementing it after the call is bad form. It's clear that this search function will at some point call wg.Done(). Should, for whatever reason, the search function return before the main function calls wg.Add(1), you'll get a runtime panic. It's easily avoided by simply incrementing your wait group before calling functions which decrement it.

When passing a waitgroup to a function, it's also more common (to the point where it's pretty much the de facto standard) to pass in the wait group first. You main function, as is, should look something like this:

wg.Add(1)
search(&wg, "/path/to/dir")
wg.Wait()

Obviously, you'll need to change the search function to match the different argument order. I'd change this function even more, but we'll cover that a bit later on.

var permit = make(chan struct{}, 30)

A global variable holding a channel works, but it's code smell. Declaring and assigning this variable in between functions if perfectly valid code, but it doesn't look clean. Code is written for people to read, compilers translate human readable code into instructions for computers to execute. Generally speaking, I prefer it when I'm able to open source files and see what types, constants, and variables it creates/declares/uses at the top of the file. The structure I tend to follow is:

 package

 imports

 constants

 variables

 types

 functions (New first, Exported functions next, unexported functions towards the bottom)

This is a common pattern to follow, I've found. In some cases you'll see variables before constants, or the types being defined first, but generally speaking, code tends to be broken down into these groups.

There is, however, a bigger concern here: because your channel is a global variable, it's not exactly "owned" by any particular function/scope. You don't explicitly close the channel, which is considered bad form. You're using this channel to ensure you have no more than 30 goroutines (not coroutines) searching the path at any given time. Once you've found what you're looking for, the channel is no longer needed, can be closed and GC'ed. That essentially means that, once the wait group is done, it should be perfectly safe to close the channel. In this case, that means the channel can be moved to the scope of main:

func main() {
    wg := sync.WaitGroup{}
    ch := make(chan struct{}, 30)
    wg.Add(1)
    search(&wg, ch, "/path/to/dir")
    wg.Wait()
    wg.Done()
    close(ch)
}

Once again, you'll need to add the channel to the search arguments to something like this:

func search(wg *sync.WaitGroup, ch chan struct{}, path string)

Now let's look at the main part of your code, this search function. You start out writing to the channel (as mentioned above to ensure no more than 30 concurrent routines). After that you're reading from the channel and decrementing the wait group in seperate defer functions. The order in which these functions are executed is LIFO. Basically, you'll decrement the waitgroup first, and drain the channel last. Reading from a closed channel is not an issue, but I thought I'd mention it. The main thing here is that there is no real reason to have 2 anonymous functions in these defer statements. You could either write:

defer func() {
    wg.Done()
    <- ch // or <-permit in your case
}()

If, for whatever reason, you want to keep these two operations as distinct defer calls, you can simply write this:

defer func() {
    <-ch // <-permit
}()
defer wg.Done() // no need to wrap this call in to a function...

As peterSO mentioned, in terms of performance improvements, you can easily replace ioutil.ReadDir with os.ReadDir. It'll return a slice of DirEntry objects, sorted by name. You can get the name, file-type, etc... through its interface, as documented here. The code should look something like this:

entries, err := os.ReadDir(path)
if err != nil {
    fmt.Println(err)
    return // exit here!
}
for _, entry := range entries {
    if entry.FileInfo().IsDir() {
        wg.Add(1)
        go search(wg, ch, filepath.Join(path, entry.Name()))
        continue
    }
    if entry.Name() == toFind {
        fmt.Printf("Found %s here: %s\n", toFind, path)
        return // return, we're done
    }
}

Having this return statement in the loop can be quite a big deal, depending on what you want your program to do. I've chosen to stop iterating over the directory items once we found a match. Having 2 files with the exact same name in a directory simply doesn't make sense, so there is no reason to keep looking in this path.

The problem here is that we could very well end up in a situation where we found a file with the name we're looking for in a particular path, but we've already spun up 29 other routines that are all recursively looking for a file with the desired name. If we only want to find one match, we might end up looking through the entire file system recursively. This could take a long time, and yield no results. Imagine you're looking for a specific file (e.g. my_app_logs_20220808_err.log. There probably is only one file with this name on your system. Now imagine someone ran this code starting from the path /. That's going to scan the entire filesystem. It could relatively quickly find /logs/my_app_logs_20220808_err.log, but the program will continue to scan the entire filesystem. You'll encounter errors when you're trying to search paths like /root or /home/some_other_user_with_encrypted_files and so on...

What you want to be able to do is to cancel the entire recursive search. Basically, you want to be able to cancel all search routines as soon as you found a match. Thankfully, golang has something that allows you to do exactly that very easily:

func main() {
    ctx, cfunc := context.WithCancel(context.Background())
    defer cfunc() // make sure this will always be called
    wg := sync.WaitGroup{}
    ch := make(ch struct{}, 30)
    wg.Add(1)
    go search(ctx, &wg, ch, cfunc, "/path/to/search")
    wg.Wait()
    close(ch)
}

Through context, we can signal to all routines within this particular context that they should stop doing whatever they do. To achieve this, all we have to do is pass in the cancel function and the context. Then just change the search function to this:

func search(ctx context.Context, wg *sync.WaitGroup, ch chan struct{}, found func(), path string) {
    entries, err := os.ReadDir(path)
    if err != nil {
        fmt.Println(err)
        return // exit here!
    }
    for _, entry := range entries {
        select {
        case <-ctx.Done(): // run is done?
            return // exit this function
        default: // if we haven't found what we're looking for, carry on
            if entry.FileInfo().IsDir() {
                wg.Add(1)
                go search(ctx, wg, ch, found, filepath.Join(path, entry.Name()))
                continue // remove this if we're lookging for directories
            }
            if entry.Name() == toFind {
                fmt.Printf("Found %s here: %s\n", toFind, path)
                found() // call the "found" callback, which cancels the context
                return // and return
            }
        }
    }
}

Lastly, in case we want to be able to run this program from the command line using arguments/flags to perform a quick recursive file search using something like:

$ my_search -f filename.foo -p /path/to/search

We have to account for our process to be interrupted/cancelled by the user. In the main function, we basically have to handle system signals. To stop all search routines and cleanly exit the program, we can simply cancel the context, signalling any and all routines to stop doing what they're doing and return:

import (
    "os"
    "os/signal"
)

func main() {
    ctx, cfunc := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
    defer cfunc()
    // the rest is the same
}

With this, the ctx.Done() channel will be closed when we receive an interrupt or kill signal, and in the search function, the select statement will enter the case <-ctx.Done() block, and return from search, execute the defer calls (decrementing the waitgroup, drain the channel). Then, the main when all this is done, the main function will close the channel and return. Nice and clean.

\$\endgroup\$
1
  • \$\begingroup\$ as a golang newbie, I find it a beautifully detailed answer. \$\endgroup\$ Commented Aug 8, 2022 at 20:30
1
\$\begingroup\$

Is there anything wrong with this code or what needs to be improved?


Yes. Read the documentation.


package io/ioutil

func ReadDir(dirname string) ([]fs.FileInfo, error)

As of Go 1.16, os.ReadDir is a more efficient and correct choice

It does make a difference.

ioutil.ReadDir:

real    0m1.390s
user    0m1.178s
sys     0m2.131s

versus

os.ReadDir:

real    0m0.841s
user    0m0.700s
sys     0m0.951s
\$\endgroup\$

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.