Это перевод блогпоста Сау Шон Чанга. В первой половине описывается его подход к созданию фотомозаики на Го, а во второй половине ускоряется выполнение программы за счёт добавления конкурентности (каналы, горутины).
По неточностям перевода пишите в личку.


Несколько месяцев назад мой хороший друг Сатиш Талим предложил отличную идею — создать несколько соревнований на Го, чтобы прокачать умения Го программистов. Идея проекта — каждый месяц (или около того) придумывать программерскую задачку, которая будет представлять собой свежий и интересный вызов для Го сообщества. Победители получат призы, но более важно, что это попытка помочь друг другу и себе в частности. Сатиш попросил меня придумать задание и я с удовольствием его придумал для третьего соревнования (challenge #3).

Будучи программистом веб приложений большую часть моей карьеры, естественной мыслью было придумать соревнование по созданию веб приложения. И недавно на хакатоне, я написал скрипт на Руби для генерации мозаики, поэтому я подумал объединить эти идеи вместе в задаче по созданию веб приложения для генерации фотомозаики.



Честно говоря, на момент публикации задачи моё веб приложение ещё не было написано. Я начал его писать уже после того как соревнование закончилось. Написание веб приложения фотомозаики заняло у меня около двух дней. Но оно не было закончено, потому что я хотел пойти дальше и добавить конкурентности в Го для ускорения программы. Этот блогпост — результат того что я сделал.

Весь код из этой записи можно найти на github.com/sausheong/mosaic. Отмечу, что код на гитхабе немного отличается от того, что представлено ниже.

Демку можно посмотреть на mosaic.saush.com. (Прим. пер. — не пугайте приложение большими картинками, сервер слабенький, поэтому оно охотно валится даже при картинках в 4 МБ). Она размещена на Digital Ocean с использованием Docker через Tutum. Производительность демки на сервере не столь высока как в этом посте, потому что она использует лишь 1 CPU VM и 512 МБ.

Создаём фотомозаику.


Фотографические мозаики или фотомозаика — это картинка (как правило, фотография), которая была разделена на прямоугольные секции (как правило, одинакового размера), каждая из которых заменяется новой картинкой. Если посмотреть издалека или прищуриться, то можно увидеть оригинальную картинку. А если посмотреть ближе, то мы увидим, что большая картинка на самом деле состоит из сотен или даже тысяч маленьких плиток-картинок.

Базовая идея проста — веб приложение позволяет загрузить пользователю необходимую картинку, на основе которой будет генерироваться мозаика. Чтобы было проще, я предположил, то картинки заранее загружены в директорию и имеют нужный размер.

Начнём с алгоритма для фотомозаики. Веб приложение состоит из простых шагов и может быть написано без использования сторонних библиотек.

  1. Создаём базу данных картинок, хэш плиток, сканируя директорию с картинками. Затем создаём отображение, где название файла — это ключ, а усреднённый цвет картинки — значение. Среднее значение цвета вычисляется для кортежа из 3 элементов: красный, зелёный, синий (RGB), — для каждого пикселя и добавляет значения всех красных, зелёных и синих пикселей, разделённых на общее кол-во пикселей. (Прим. пер. — автор употребляет именно термин tuple, но в Го нет кортежей как таковых, а массивы очень похожи по свойствам. Не путать со слайсами).
  2. Разрезаем целевую картинку на маленькие плитки нужного размера.
  3. Для каждой секции целевой картинки вычисляем средний цвет верхнего левого пикселя и предполагаем, что это средний цвет всей секции.
  4. Ищем подходящую плитку из БД плиток, которая лучше всего совпадает со средним цветом соответствующей секции целевой картинки, и размещаем ее на подходящее место в фотомазаике. Для поиска лучшего совпадения мы вычисляем Евклидово расстояние между двумя цветами [3]кортежа путём преобразования каждого цвета [3]кортежа в точку в трёхмерном пространстве.
  5. Удаляем найденные плитки из БД, чтобы оставшиеся плитки оставались уникальными.

Я разместил весь написанный код для создания мозаики в единственном файле mosaic.go. Давайте познакомимся с каждой функцией из этого файла.

// ищем средний цвет картинки
func averageColor(img image.Image) [3]float64 {
    bounds := img.Bounds()
    r, g, b := 0.0, 0.0, 0.0
    for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
        for x := bounds.Min.X; x < bounds.Max.X; x++ {
            r1, g1, b1, _ := img.At(x, y).RGBA()
            r, g, b = r+float64(r1), g+float64(g1), b+float64(b1)
        }
    }
    totalPixels := float64(bounds.Max.X * bounds.Max.Y)
    return [3]float64{r / totalPixels, g / totalPixels, b / totalPixels}
}

Начинаем с функции averageColor, которая вычисляет значения всех красных, зелёных и синих пикселей изображения, затем суммирует их (по отдельности) и делит каждую сумму значений на общее количество пикселей картинки. Затем мы возвращаем [3]кортеж (в действительности массив из 3 элементов) состоящий из этих значений.

Далее посмотрим на функцию уменьшения картинки (resize)

// изменяем размер изображения на новое значение newWidth
func resize(in image.Image, newWidth int) image.NRGBA {
    bounds := in.Bounds()
    width := bounds.Max.X - bounds.Min.X
    ratio := width / newWidth
    out := image.NewNRGBA(image.Rect(bounds.Min.X/ratio, bounds.Min.X/ratio, bounds.Max.X/ratio, bounds.Max.Y/ratio))
    for y, j := bounds.Min.Y, bounds.Min.Y; y < bounds.Max.Y; y, j = y+ratio, j+1 {
        for x, i := bounds.Min.X, bounds.Min.X; x < bounds.Max.X; x, i = x+ratio, i+1 {
            r, g, b, a := in.At(x, y).RGBA()
            out.SetNRGBA(i, j, color.NRGBA{uint8(r), uint8(g), uint8(b), uint8(a)})
        }
    }
    return *out
}

Функция tilesDB создаёт базу данных картинок, сканируя директорию с изображениями.

// объявляем tilesDB в памяти
func tilesDB() map[string][3]float64 {
    fmt.Println("Start populating tiles db ...")
    db := make(map[string][3]float64)
    files, _ := ioutil.ReadDir("tiles")
    for _, f := range files {
        name := "tiles/" + f.Name()
        file, err := os.Open(name)
        if err == nil {
            img, _, err := image.Decode(file)
            if err == nil {
                db[name] = averageColor(img)
            } else {
                fmt.Println(":", err, name)
            }
        } else {
            fmt.Println("cannot open file", name, err)
        }
        file.Close()
    }
    fmt.Println("Finished populating tiles db.")
    return db
}

БД плиток — это отображение со строкой в ключе и [3]кортежем (в этом случае массив из трёх элементов) в значении. Я открываю каждый файл изображения в директории и вычисляю средний цвет изображения, чтобы заполнить данными значения отображения. БД плиток используется для поиска подходящей плитки в директории изображений. Она передаётся в функцию nearest, наряду с целевым значением цвета [3]кортежа.

// ищет максимально близко совпадающее изображение
func nearest(target [3]float64, db *map[string][3]float64) string {
    var filename string
    smallest := 1000000.0
    for k, v := range *db {
        dist := distance(target, v)
        if dist < smallest {
            filename, smallest = k, dist
        }
    }
    delete(*db, filename)
    return filename
}

Каждое значение в БД сравнивается с целевым цветами, затем значение с наименьшей разницей возвращается как наиболее подходящая плитка. Найденное значение удаляется из БД. Функция distance вычисляет Евклидово расстояние между двумя [3]кортежами.

// возвращает Евклидово расстояние между двумя точками
func distance(p1 [3]float64, p2 [3]float64) float64 {
    return math.Sqrt(sq(p2[0]-p1[0]) + sq(p2[1]-p1[1]) + sq(p2[2]-p1[2]))
}
 
// возвращает квадрат
func sq(n float64) float64 {
    return n * n
}

Наконец, сканирование и загрузка БД плиток при создании каждой фотомозаики будет выглядеть неуклюже. Я хочу делать это лишь однажды, а затем клонировать эту БД при создании фотомозаик. Исходная БД плиток TILESDB создаётся как глобальная переменная и объявляется в начале веб приложения.

var TILESDB map[string][3]float64
 
// клонируем БД плиток каждый раз при генерации фотомозаики
func cloneTilesDB() map[string][3]float64 {
    db := make(map[string][3]float64)
    for k, v := range TILESDB {
        db[k] = v
    }
    return db
}

Веб приложение фотомозаики


После подготовки функций для генерации фотомозаики, можно приступать к написанию моего веб приложения. Веб приложение будет находиться в исходном файле с именем main.go.

package main
 
import (
    "fmt"
    "html/template"
    "net/http"
    "bytes"
    "encoding/base64"
    "image"
    "image/draw"
    "image/jpeg"
    "os"
    "strconv"
)
 
func main() {
    mux := http.NewServeMux()
    files := http.FileServer(http.Dir("public"))
    mux.Handle("/static/", http.StripPrefix("/static/", files))
    mux.HandleFunc("/", upload)
    mux.HandleFunc("/mosaic ", mosaic)
    server := &http.Server{
        Addr:    "127.0.0.1:8080",
        Handler: mux,
    }
// собираем исходную БД плиток
    TILESDB = tilesDB()
    fmt.Println("Mosaic server started.")
    server.ListenAndServe()
}
 
// отображение страницы загрузки
func upload(w http.ResponseWriter, r *http.Request) {
    t, _ := template.ParseFiles("upload.html")
    t.Execute(w, nil)
}
 
// HandlerFunc mosaic содержит все алгоритмы для генерации фотомозаики
func mosaic(w http.ResponseWriter, r *http.Request) {
    t0 := time.Now()
    // получение данных из POST формы
    r.ParseMultipartForm(10485760) // max body in memory is 10MB
    file, _, _ := r.FormFile("image")
    defer file.Close()
    tileSize, _ := strconv.Atoi(r.FormValue("tile_size"))
    // декодирование и получение оригинального изображения
    original, _, _ := image.Decode(file)
    bounds := original.Bounds()
    // создание нового изображения для мозаики
    newimage := image.NewNRGBA(image.Rect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, bounds.Max.Y))
    // создание плиточной БД
    db := cloneTilesDB()
    // source point для каждой плитки с дефолтными значениями 0, 0 для каждой плитки
    sp := image.Point{0, 0}
    for y := bounds.Min.Y; y < bounds.Max.Y; y = y + tileSize {
        for x := bounds.Min.X; x < bounds.Max.X; x = x + tileSize {
            // используем левый верхний пиксель как усреднённый цвет
            r, g, b, _ := original.At(x, y).RGBA()
            color := [3]float64{float64(r), float64(g), float64(b)}
            // получение подходящей плитки из БД
            nearest := nearest(color, &db)
            file, err := os.Open(nearest)
            if err == nil {
                img, _, err := image.Decode(file)
                if err == nil {
                    // изменение размера плитки к необходимым значеням
                    t := resize(img, tileSize)
                    tile := t.SubImage(t.Bounds())
                    tileBounds := image.Rect(x, y, x+tileSize, y+tileSize)
                    // отрисовка плитки в мозаике
                    draw.Draw(newimage, tileBounds, tile, sp, draw.Src)
                } else {
                    fmt.Println("error:", err, nearest)
                }
            } else {
                fmt.Println("error:", nearest)
            }
            file.Close()
        }
    }
 
    buf1 := new(bytes.Buffer)
    jpeg.Encode(buf1, original, nil)
    originalStr := base64.StdEncoding.EncodeToString(buf1.Bytes())
 
    buf2 := new(bytes.Buffer)
    jpeg.Encode(buf2, newimage, nil)
    mosaic := base64.StdEncoding.EncodeToString(buf2.Bytes())
    t1 := time.Now()
    images := map[string]string{
        "original": originalStr,
        "mosaic":   mosaic,
        "duration": fmt.Sprintf("%v ", t1.Sub(t0)),
    }
    t, _ := template.ParseFiles("results.html")
    t.Execute(w, images)
}

Основная логика создание фотомозаики описана в функции mosaic которая является хендлером. Сначала я получаю загруженный файл и размер плиток из формы.

// получение данных из POST формы
r.ParseMultipartForm(10485760) // максимальный размер 10 МБ
file, _, _ := r.FormFile("image")
defer file.Close()
tileSize, _ := strconv.Atoi(r.FormValue("tile_size"))

Далее я декодирую загруженное целевое изображение и создаю новое изображение для фотомозаики.

// декодирование и получение оригинального изображения
original, _, _ := image.Decode(file)
bounds := original.Bounds()
// создание нового изображения для мозаики
newimage := image.NewNRGBA(image.Rect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, bounds.Max.Y))

Ещё клонирую исходную БД плиток и определяю исходные точки каждой плитки (нужно далее для пакета image/draw)

// создание плиточной БД
db := cloneTilesDB()
// source point для каждой плитки с дефолтными значениями 0, 0 для каждой плитки
sp := image.Point{0, 0}

Сейчас мы готовы к итерациям по всем плиткам выбранного размера в целевом изображении.

 for y := bounds.Min.Y; y < bounds.Max.Y; y = y + tileSize {
	for x := bounds.Min.X; x < bounds.Max.X; x = x + tileSize {
		// используем левый верхний пиксель как усреднённый цвет
		r, g, b, _ := original.At(x, y).RGBA()
		color := [3]float64{float64(r), float64(g), float64(b)}
		// получение подходящей плитки из БД
		nearest := nearest(color, &db)
		file, err := os.Open(nearest)
		if err == nil {
			img, _, err := image.Decode(file)
			if err == nil {
				// изменение размера плитки к необходимым значеням
				t := resize(img, tileSize)
				tile := t.SubImage(t.Bounds())
				tileBounds := image.Rect(x, y, x+tileSize, y+tileSize)
				// отрисовка плитки в мозаике
				draw.Draw(newimage, tileBounds, tile, sp, draw.Src)
			} else {
				fmt.Println("error:", err, nearest)
			}
		} else {
			fmt.Println("error:", nearest)
		}
		file.Close()
	}
}

Для каждой секции выбираем левый верхний пиксель и решаем что это усреднённый цвет секции. Далее ищем наиболее подходящую по цвету плитку в БД. Плиточная БД выдаёт имя файла, размер которого мы изменяем на заданное. Результирующая плитка помещается в созданную ранее мозаику (newimage).

После того, как фотомозаика создана, перекодирую его в формате JPEG, а затем снова перекодирую его в base64 строку.

buf1 := new(bytes.Buffer)
jpeg.Encode(buf1, original, nil)
originalStr := base64.StdEncoding.EncodeToString(buf1.Bytes())
 
buf2 := new(bytes.Buffer)
jpeg.Encode(buf2, newimage, nil)
mosaic := base64.StdEncoding.EncodeToString(buf2.Bytes())
t1 := time.Now()
images := map[string]string{
    "original": originalStr,
    "mosaic":   mosaic,
    "duration": fmt.Sprintf("%v ", t1.Sub(t0)),
}
t, _ := template.ParseFiles("results.html")
t.Execute(w, images)

Оригинальное целевое изображение и фотомозаика посылаются в шаблон results.html для отображения на следующей странице. Как видите, картинка отображается в data URL как встроенный прямо в веб страницу base64 контент.

<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    <title>Mosaic</title>
    ...
  </head>   
  <body>
    <div class='container'>
        <div class="col-md-6">
          <img src="data:image/jpg;base64,{{ .original }}" width="100%">
          <div class="lead">Original</div>
        </div>
        <div class="col-md-6">
          <img src="data:image/jpg;base64,{{ .mosaic }}" width="100%">
          <div class="lead">Mosaic – {{ .duration }} </div>
        </div>
        <div class="col-md-12 center">
          <a class="btn btn-lg btn-info" href="/">Go Back</a>
        </div>
    </div>   
    <br>
  </body>
</html>

Скриншот созданной мозаики:

Базовая фотомозаика

Теперь, когда у нас есть базовое веб приложение для генерации фотомозаики, давайте добавим версию с легковесными потоками и одновременным выполнением функций.

Конкурентное веб приложение фотомозаики


Одна из наиболее частых задач при использовании конкурентности — улучшение производительности. Веб приложение, которое я написал создаёт фотомозаику из картинки в 151 КБ JPEG за 2,25 секунды. Скорость не поражает и определённо может быть улучшена при добавлении легковесных потоков. В этом примере буду использовать довольно простой алгоритм одновременного выполнения горутин.

  1. Делим оригинальное изображение на 4 части
  2. Обрабатываем их одновременно
  3. Соединяем результат обратно в одну мозаику

Это можно представить в таком виде:

Конкурентный алгоритм

Отмечу, что это не единственный способ улучшить производительность при помощи легковесных потоков, но это единственный простой и прямой путь.

Единственная функция, которую мы будем менять — это хэндлер mosaic. Раньше у нас был единственный хэндлер для создания фотомозаики. В конкурентной версии веб приложения фотомозаики нужно разделить её на две новые функции, которые будут разделять (cut) и соединять (combine) картинку. И cut, и combine мы будем вызывать из функции mosaic.

func mosaic(w http.ResponseWriter, r *http.Request) {
    t0 := time.Now()
    r.ParseMultipartForm(10485760) // максимальный размер 10 МБ
    file, _, _ := r.FormFile("image")
    defer file.Close()
    tileSize, _ := strconv.Atoi(r.FormValue("tile_size"))
    original, _, _ := image.Decode(file)
    bounds := original.Bounds()
    db := cloneTilesDB()
 
    // разделяем
    c1 := cut(original, &db, tileSize, bounds.Min.X, bounds.Min.Y, bounds.Max.X/2, bounds.Max.Y/2)
    c2 := cut(original, &db, tileSize, bounds.Max.X/2, bounds.Min.Y, bounds.Max.X, bounds.Max.Y/2)
    c3 := cut(original, &db, tileSize, bounds.Min.X, bounds.Max.Y/2, bounds.Max.X/2, bounds.Max.Y)
    c4 := cut(original, &db, tileSize, bounds.Max.X/2, bounds.Max.Y/2, bounds.Max.X, bounds.Max.Y)
 
    // соединяем
    c := combine(bounds, c1, c2, c3, c4)
 
    buf1 := new(bytes.Buffer)
    jpeg.Encode(buf1, original, nil)
    originalStr := base64.StdEncoding.EncodeToString(buf1.Bytes())
 
    t1 := time.Now()
    images := map[string]string{
        "original": originalStr,
        "mosaic":   <-c,
        "duration": fmt.Sprintf("%v ", t1.Sub(t0)),
    }
 
    t, _ := template.ParseFiles("results.html")
    t.Execute(w, images)
}

Разрезанное изображение обрабатывается функцией cut.


Разделение целевого изображения на 4 части

Оригинальное изображение режется на 4 квадранта для того чтобы обрабатывать их одновременно.

c1 := cut(original, &db, tileSize, bounds.Min.X, bounds.Min.Y, bounds.Max.X/2, bounds.Max.Y/2)
c2 := cut(original, &db, tileSize, bounds.Max.X/2, bounds.Min.Y, bounds.Max.X, bounds.Max.Y/2)
c3 := cut(original, &db, tileSize, bounds.Min.X, bounds.Max.Y/2, bounds.Max.X/2, bounds.Max.Y)
c4 := cut(original, &db, tileSize, bounds.Max.X/2, bounds.Max.Y/2, bounds.Max.X, bounds.Max.Y)

Наверное вы заметили, что это обычные функции без горутин, как они могут выполняться одновременно? Дело в том, что функция cut внутри себя создаёт анонимную горутину и возвращает канал.

func cut(original image.Image, db *map[string][3]float64, tileSize, x1, y1, x2, y2 int) <-chan image.Image {
    c := make(chan image.Image)
    sp := image.Point{0, 0}
    go func() {
        newimage := image.NewNRGBA(image.Rect(x1, y1, x2, y2))
        for y := y1; y < y2; y = y + tileSize {
            for x := x1; x < x2; x = x + tileSize {
                r, g, b, _ := original.At(x, y).RGBA()
                color := [3]float64{float64(r), float64(g), float64(b)}
                nearest := nearest(color, db)
                file, err := os.Open(nearest)
                if err == nil {
                    img, _, err := image.Decode(file)
                    if err == nil {
                        t := resize(img, tileSize)
                        tile := t.SubImage(t.Bounds())
                        tileBounds := image.Rect(x, y, x+tileSize, y+tileSize)
                        draw.Draw(newimage, tileBounds, tile, sp, draw.Src)
                    } else {
                        fmt.Println("error:", err)
                    }
                } else {
                    fmt.Println("error:", nearest)
                }
                file.Close()
            }
        }
        c <- newimage.SubImage(newimage.Rect)
    }()
    return c
}

Логика точно такая же, как и в первоначальном веб приложении. Я создаю канал в функции cut и объявляю анонимную горутину, которая посылает результат вычислений в этот канал. Затем возвращаю этот канал. Таким образом канал немедленно возвращается функции-обработчику mosaic, а сегмент фотомозаики отправляется в этот канал как только будет полностью обработан.

Я разделял оригинальное изображение на 4 части и конвертировал их по отдельности в фотомозаику. Наступило время собрать части обратно в единую картинку.

func combine(r image.Rectangle, c1, c2, c3, c4 <-chan image.Image) <-chan string {
    c := make(chan string)
    // стартуем горутину
    go func() {
        var wg sync.WaitGroup
        img := image.NewNRGBA(r)
        copy := func(dst draw.Image, r image.Rectangle, src image.Image, sp image.Point) {
            draw.Draw(dst, r, src, sp, draw.Src)
            wg.Done()
        }
        wg.Add(4)
        var s1, s2, s3, s4 image.Image
        var ok1, ok2, ok3, ok4 bool
        for  {
            select {
            case s1, ok1 = <-c1:
                go copy(img, s1.Bounds(), s1, image.Point{r.Min.X, r.Min.Y})
            case s2, ok2 = <-c2:
                go copy(img, s2.Bounds(), s2, image.Point{r.Max.X / 2, r.Min.Y})
            case s3, ok3 = <-c3:
                go copy(img, s3.Bounds(), s3, image.Point{r.Min.X, r.Max.Y/2})
            case s4, ok4 = <-c4:
                go copy(img, s4.Bounds(), s4, image.Point{r.Max.X / 2, r.Max.Y / 2})
            }
            if (ok1 && ok2 && ok3 && ok4) {
                break
            }
        }
        // ждём пока все горутины закончат работу
        wg.Wait()
        buf2 := new(bytes.Buffer)
        jpeg.Encode(buf2, newimage, nil)
        c <- base64.StdEncoding.EncodeToString(buf2.Bytes())
    }()
    return c
}

Как и в сut функции, основная логика соединения изображения находится в горутине, а канал создан только для приёма данных.

В анонимной горутине создана ещё одна анонимная функция, которой присвоена переменная copy. Эта функция копирует сегмент фотомозаики в финальную мозаику. Позже она будет запускаться как отдельная горутина, поэтому невозможно узнать когда она выполнится. Чтобы решить эту проблему и синхронизировать выполнение функции копирования, будем использовать WaitGroup. Создаём переменную wg типа WaitGroup, затем используем метод Add и устанавливаем счётчик на 4. Каждый раз при выполнении функции copy будет вызываться метод Done, который уменьшает значение счётчика на 1. Метод Wait вызывается строго до начала кодирования изображения, чтобы гарантировать генерацию всех четырёх частей мозаики для получения цельной картинки.

Вспомним, что функция combine принимает 4 канала из cut функии, содержащих сегменты фотомазаики. На самом деле не ясно когда именно сегменты появятся в каналах. Можно попробовать получить эти сегменты последовательно, но это не похоже на конкурентный подход. Но хотелось бы запускать обработку сегмента сразу как только он будет получен при помощи оператора select.

var s1, s2, s3, s4 image.Image
var ok1, ok2, ok3, ok4 bool
for  {
	select {
	case s1, ok1 = <-c1:
		go copy(img, s1.Bounds(), s1, image.Point{r.Min.X, r.Min.Y})
	case s2, ok2 = <-c2:
		go copy(img, s2.Bounds(), s2, image.Point{r.Max.X / 2, r.Min.Y})
	case s3, ok3 = <-c3:
		go copy(img, s3.Bounds(), s3, image.Point{r.Min.X, r.Max.Y / 2})
	case s4, ok4 = <-c4:
		go copy(img, s4.Bounds(), s4, image.Point{r.Max.X / 2, r.Max.Y / 2})
	}
	if (ok1 && ok2 && ok3 && ok4) {
		break
	}
}

Это бесконечный цикл, который на каждой итерации пытается выбрать case с готовыми данными (если одновременно пришли данные по нескольким каналам, то Го случайно выбирает один case и исполняет его). При получении image.Image для канала, запускаем горутину функциии copy. Замечу, что каналы принимает несколько значений. Второе значение (ok1, ok2, ok3 or ok4) сообщает нам о факте получения данных из канала. Бесконечный цикл прерывается после получения данных их всех четырёх каналов.

Далее вспомним тип WaitGroup, который использовали ранее. Помните, функция combine соединяет полученные части фотомозаики в отдельных горутинах, которые могут завершиться не в одно и то же время. Поэтому метод Wait типа WaitGroup блокирует кодирование изображение до тех пор, пока все части не будут собраны воедино.

Скриншот с той же картинкой и результатом генерации мозаики:


Конкурентное веб приложение фотомозаики

Ваш зоркий глаз может заметить различия между сгенерированными фотомозаиками. Финальная мозаика собрана из 4 частей и алгоритм не смягчает грубые края. Однако, разница в производительности очевидна — базовое приложение генерировало мозаику за 2,25 секунды, а конкурентная реализация выполняет то же самое в четыре раза быстрее, за 646 миллисекунды.

Внимательный читатель мог отметить, что оба веб приложения запускаются лишь на одном ядре процессора. Как пишет Роб Пайк в своей статье "Конкурентность — не параллельность", — это то как я взял простой алгоритм и разделил его на легковесные потоки без параллельного вызова! Ни одна из горутин не запущена параллельно (ведь используется лишь 1 CPU), несмотря на то что они запускаются независимо.

Конечно, было бы жестоко не сделать последний шаг, который покажет как всё это запустить на нескольких ядрах вашего процессора. Для этого нужно просто установить GOMAXPROCS в рантайме равным количеству ядер вашего процессора. Изменения нужно ввести в файле main.go. Не забудьте импортировать пакет runtime перед внесением изменений.
(Прим. пер. — не актуально, начиная с версии Го 1,5. Дело в том, что до 1,5 дефолтным значением GOMAXPROCS было 1, как и говорит автор. Начиная с 1,5 дефолтный GOMAXPROCS будет равен числу ваших ядер)

func main() {
    // выводит количество ядер вашей системы
    fmt.Println("Number of CPUs:", runtime.NumCPU())
    runtime.GOMAXPROCS(runtime.NumCPU())
    fmt.Println("Starting mosaic server ...")
    mux := http.NewServeMux()
    files := http.FileServer(http.Dir("public"))
    mux.Handle("/static/", http.StripPrefix("/static/", files))
    mux.HandleFunc("/", upload)
    mux.HandleFunc("/mosaic", mosaic)
    server := &http.Server{
        Addr:    "127.0.0.1:8080",
        Handler: mux,
    }
    TILESDB = tilesDB()
    fmt.Println("Mosaic server started.")
    server.ListenAndServe()
}

Скомпилировал и снова загрузил нашу картинку с котиком:


Конкурентное веб приложение фотомозаике, запущенное на 8 CPU

Как видите, скорость выросла ещё в 3 раза: с 646 миллисекунд до 216 миллисекунд! А если сравнивать это время с нашим оригинальным веб приложением, которое генерировало мозаику за 2,25 секунды, то производительность увеличилась в 10 раз! Это реальное сравнение. Мы не запускали наше первоначальное приложение на 8 ядрах, но если бы мы это сделали, то не увидели бы прирост производительности, так как оно не использовало конкурентность.

Ещё интересно отметить, что мы использовали одинаковый алгоритм для однопоточной и конкурентной реализации алгоритма генерации фотомозаики. По факту мы не вносили изменений в файл mosaic.go. The only difference is concurrency, and that is a testament to how powerful it is.

Комментарии (12)


  1. UnickSoft
    21.07.2015 11:49
    +1

    Я смотрю, что работа с пикслеми проводится во float-ах. Не знаю как в Go, но вот в C++ перевод такого же кода на целые числа дал бы существенный прирост производительности.


    1. Pryada Автор
      21.07.2015 14:14

      Сначала подумал, что это ограничение стандартной библиотеки, но я посмотрел, там везде int да uint. Не знаю как обосновать выбор автора. Если ответит мне в фейсбуке, то задам ему этот вопрос.
      Наверняка с целыми числами будет быстрей.


  1. gentee
    21.07.2015 13:28
    +1

    А мне вот какой вопрос не понятен. Разве не логично применять average не к оригинальным картинкам из директории, а в начале уменьшить их до размера плиток, а затем уже считать средний цвет. Мне кажется в этом случае картинки более качественно подбираться будут.


    1. Pryada Автор
      21.07.2015 13:41

      Для этого пришлось бы создавать несколько новых директорий, сохранять там результат изменений, хранить усреднённые цвета в разных отображениях. Или просто с префиксом генерировать уменьшенные картинки в ту же директорию, тогда всё бы помещалось в уже созданное отображение, но непомерно увеличивало бы его.
      Это всё ещё раздуло бы эту статью, а в ней и так около 20 страниц текста.

      Задача статьи — показать как с помощью Го ускорить приложение, добавив немножко горутин.


      1. gentee
        21.07.2015 14:05

        Статья полезная, мне понравилась, но кто мешал автору перед вычислением average сделать уменьшенную копию изображения в памяти без всякого сохранения. Ну да, у нас размер плиток может разный быть, тогда взять какое-нибудь максимальное значение, скажем в пикселей 50. Хотя, если картинки в директории уже маленькие, то вопрос отпадает.


        1. Pryada Автор
          21.07.2015 14:21
          +1

          Картинки изначально размером 150х150 пикселей. Приложение ресайзит в 8 размеров от 10 до 100 пикселей.

          Но в целом да, ничего не мешало. Улучшить саму генерацию мозаики точно можно.


  1. deniskreshikhin
    21.07.2015 17:58

    По-моему не очень удачный пример, все-таки для обработки изображения лучше воспользоваться хостингом с GPU и OpenCL/CUDA.
    Все-таки goroutins лучше подходят для задач которые не могут быть решены с использованием аппаратного ускорения в принципе (обработка запросов, работа с бд, работа с текстом, парсинг и т.д.)


  1. vladkens
    21.07.2015 23:45
    +1

    Я не понял, почему в программах на одном ядре есть такая разница в производительности? Алгоритм же линеен и по сути только зависит от производительности процессора, а операций типа I/O на которых включается конкурентность особо и нет.


    1. dblokhin
      22.07.2015 05:50
      +1

      Не понимаю почему вас минусуют, замечание вполне уместное.
      Мне тоже не ясна причина производительности с использованием go-рутин на 1 ядре. Ведь количество шагов, инструкций процессора будет одинаковым, без разницы в каком порядке будут выполнены go-подпрограммы и на какое количество псевдо-потоков разделится программный код.

      В чем именно оптимизация пока непонятна, однако складывается ощущение, что автор недостаточно понимает смысл concurrency:

      для того чтобы обрабатывать их одновременно.


      1. divan0
        23.07.2015 18:15
        +1

        Не сильно понял ваше удивление. Есть 4 горутины, которые в случае 1 ядра выполняются *по-факту* последовательно, в случае нескольких — разбрасываются шедулером на несколько ядер и выполняются параллельно. 4-х кратное ускорение вполне логично.

        UPD. Или я неверно понял, что именно удивляет?


        1. vladkens
          23.07.2015 19:53
          +1

          Судя по тексту автор гонял код на одном ядре:

          … базовое приложение генерировало мозаику за 2,25 секунды, а конкурентная реализация выполняет то же самое в четыре раза быстрее, за 646 миллисекунды.

          Внимательный читатель мог отметить, что оба веб приложения запускаются лишь на одном ядре процессора.


    1. M0sTH8
      23.07.2015 20:21

      Оптимизация по скорости может быть потому, что алгоритм утилизировал не все ресурсы процессора до того как его разбили на четыре части.