Многопоточность и параллелизм в Go: Goroutines и каналы

3e7hiknk7jlhpv7oiqa_spa15t8.jpeg

Язык программирования Go, разработанный с упором на простоту и эффективность, предлагает уникальный подход к реализации параллельных вычислений через Goroutines и каналы.

Goroutines, представляющие собой легковесные потоки выполнения, обеспечивают значительные преимущества по сравнению с традиционными потоками, используемыми во многих других языках программирования. Они позволяют создавать тысячи параллельных процессов без значительной нагрузки на системные ресурсы. Каналы в Go, предоставляя мощный механизм для безопасной коммуникации между Goroutines, дополнительно упрощают управление параллельными задачами и обмен данными.


Разработка Go началась в 2007 году в Google, когда Роб Пайк, Кен Томпсон, и Роберт Грисемер начали работу над новым языком программирования. Одной из ключевых целей было создание языка, который упрощал бы разработку многопоточных программ и управление параллелизмом, особенно в контексте современных многопроцессорных и сетевых систем.

Основным источником вдохновения для Goroutines послужила модель CSP (Коммуникативные последовательные процессы), разработанная Тони Хоаром в 1970-х годах. CSP подчеркивает важность коммуникации между параллельными процессами через каналы, что стало основой для Goroutines и каналов в Go.

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

Со временем было сделано множество улучшений в планировщике Go, который управляет Goroutines. Эти улучшения направлены на повышение производительности и эффективности, особенно в системах с большим количеством процессоров.

Горутина состоит из 70 в коде и выглядит следующим образом:

type g struct {
    stack            stack   
    stackguard0      uintptr 
    stackguard1      uintptr 
    _panic           *_panic 
    _defer           *_defer 
    m                *m      
    sched            gobuf
    syscallsp        uintptr        
    syscallpc        uintptr        
    stktopsp         uintptr        
    param            unsafe.Pointer 
    atomicstatus     uint32
    stackLock        uint32 
    goid             int64
    schedlink        guintptr
    waitsince        int64      
    waitreason       waitReason
    preempt          bool 
    preemptStop      bool 
    preemptShrink    bool 
    asyncSafePoint   bool
    paniconfault     bool 
    gcscandone       bool 
    throwsplit       bool 
    activeStackChans bool
    raceignore       int8     
    sysblocktraced   bool     
    sysexitticks     int64   
    traceseq         uint64   
    tracelastp       puintptr 
    lockedm          muintptr
    sig              uint32
    writebuf         []byte
    sigcode0         uintptr
    sigcode1         uintptr
    sigpc            uintptr
    gopc             uintptr         
    ancestors        *[]ancestorInfo 
    startpc          uintptr         
    racectx          uintptr
    waiting          *sudog        
    cgoCtxt          []uintptr     
    labels           unsafe.Pointer
    timer            *timer        
    selectDone       uint32        
    gcAssistBytes    int64
}


Эта структура содержит информацию, необходимую для управления выполнением Goroutine, включая состояние стека, контекст планировщика и другие важные сведения:

1. stack: Структура, описывающая стек этой Goroutine. Содержит указатели на нижнюю и верхнюю границы стека.

2. stackguard0 и stackguard1 (uintptr): Используются для реализации проверок переполнения стека.

3. _panic : Указатель на структуру паники, если эта Goroutine находится в состоянии паники.

4. _defer: Указатель на структуру отложенного вызова, используемую для реализации механизма `defer`.

5. m: Указатель на структуру машины (M), с которой связана эта Goroutine. В Go машина представляет поток ОС.

6. sched (gobuf): Структура, содержащая контекст планировщика, включая указатели на стек и регистры.

7. syscallsp, syscallpc (uintptr): Используются при выполнении системных вызовов.

8. param (unsafe.Pointer): Произвольный параметр, используемый для передачи данных между Goroutines.

9. atomicstatus (uint32): Статус Goroutine, используемый для управления ее состоянием (например, выполнение, ожидание и т.д.).

10. stackLock (uint32): Блокировка для управления доступом к стеку Goroutine.

11. goid (int64): Глобальный уникальный идентификатор Goroutine.

12. preempt, preemptStop, preemptShrink (bool): Флаги, используемые для управления прерыванием выполнения Goroutine.

13. waiting (*sudog): Указатель на объект ожидания, связанный с Goroutine.

14. gcAssistBytes (int64): Количество байтов, которые Goroutine должна помочь выделить для сборщика мусора.

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

Для создания новой Goroutine достаточно использовать ключевое слово go, за которым следует вызов функции или метода.

   go myFunction()

myFunction будет выполнена как отдельная Goroutine.

Также можно использовать анонимные функции для запуска Goroutines.

   go func() {
       // Код для выполнения в Goroutine
   }()

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

Планировщик горутин


Планировщик обеспечивает эффективное и справедливое управление параллельным выполнением Goroutines. Он работает на уровне языка и отличается от традиционных планировщиков потоков операционной системы.

Планировщик Goroutines в Go мультиплексирует все активные Goroutines на ограниченное количество потоков ОС. Это позволяет эффективно использовать многопроцессорность без создания избыточного количества потоков ОС.

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

Планировщик использует алгоритм «кражи работы» для балансировки нагрузки между потоками. Если один поток ОС завершает выполнение своих Goroutines, он может «украсть» Goroutines из очереди другого потока для обеспечения равномерного распределения работы.

Пример, иллюстрирующий работу планировщика Goroutines:

package main

import (
    "fmt"
    "runtime"
    "time"
)

func printNumbers(prefix string) {
    for i := 0; i < 5; i++ {
        fmt.Printf("%s: %d\n", prefix, i)
        time.Sleep(1 * time.Millisecond) // Имитация длительной работы
    }
}

func main() {
    runtime.GOMAXPROCS(1) // Ограничение использования одним процессорным ядром
    go printNumbers("Goroutine1")
    go printNumbers("Goroutine2")
    time.Sleep(100 * time.Millisecond) // Дать время для завершения Goroutines
}

В этом примере:

— Устанавливается GOMAXPROCS(1), чтобы ограничить выполнение на одном процессорном ядре.
— Запускаются две Goroutines, каждая из которых печатает числа с задержкой.
— Планировщик Go будет мультиплексировать эти Goroutines на одном потоке ОС, попеременно выделяя им время CPU для выполнения.

Стэк


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

Стек Goroutine может динамически расширяться и сжиматься в зависимости от потребностей. Это означает, что Goroutine использует только тот объем памяти, который ей действительно необходим, и может адаптироваться к изменяющимся требованиям памяти во время выполнения.

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

Когда Goroutine создается, ей выделяется стек небольшого размера. Этот размер достаточен для выполнения большинства операций, не требующих значительного объема памяти.

Если Goroutine требует больше памяти, чем доступно в ее текущем стеке, стек автоматически расширяется (как правило, удваивается) для предоставления дополнительной памяти. Подобным образом, стек может сжиматься, когда большой объем памяти больше не требуется.

Переменные, выделенные в Goroutine, которые должны сохраняться после завершения функции, автоматически перемещаются на кучу. Это управление памятью выполняется автоматически компилятором Go.

Легковесность Goroutines позволяет создавать и управлять большим количеством параллельных задач с минимальными затратами ресурсов, что делает Go особенно подходящим для высокопроизводительных и масштабируемых приложений.

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

Блокировка и неблокировка в контексте Goroutines в языке программирования Go относятся к способу, которым Goroutines взаимодействуют друг с другом и с различными ресурсами системы. Эти концепции имеют ключевое значение для понимания параллелизма и конкуренции в Go.

Блокировка в Goroutines происходит, когда выполнение Goroutine приостанавливается до тех пор, пока не будет выполнено определенное условие или действие.

При отправке данных в небуферизированный канал, Goroutine блокируется, пока другая Goroutine не прочитает данные из этого канала. Аналогично, чтение из небуферизированного канала блокируется, пока в него не будут отправлены данные.

В буферизированных каналах блокировка происходит, когда буфер заполнен при отправке и пуст при попытке чтения. (про типы каналов чуть позже)

Goroutine может быть заблокирована при попытке захвата мьютекса, который уже занят другой Goroutine. Goroutine также может блокироваться при вызове Wait() на sync.WaitGroup, пока другие Goroutines не вызовут Done().

Сетевые запросы или чтение/запись файлов могут вызвать блокировку Goroutine до завершения операции.

Неблокировка означает, что выполнение Goroutine продолжается без остановки, даже если другие операции еще не завершены.

Можно использовать конструкцию select с веткой default для неблокирующей отправки или получения данных из канала:

  select {
  case ch <- data:
      // отправлено успешно
  default:
      // продолжить выполнение, не ожидая
  }

Неправильное управление блокировками может привести к снижению производительности и даже к мертвым замкам (deadlocks), когда несколько Goroutines взаимно блокируют друг друга.


Канал в Go — это механизм передачи данных, который позволяет безопасно передавать значения между различными Goroutines.

Отправка и прием данных через каналы в Go является ключевой частью работы с Goroutines, обеспечивая безопасный и эффективный механизм взаимодействия между параллельно выполняемыми задачами. Давайте подробно рассмотрим, как именно происходит обмен данными через каналы и какие практики применяются в контексте Goroutines.

Отправка данных в канал в Go осуществляется с помощью оператора <-. Например, если у вас есть канал ch типа chan int, вы можете отправить в него значение, используя ch <- 10.

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

   ch := make(chan int)
   go func() {
       result := someLongComputation()
       ch <- result
   }()

Для получения данных из канала также используется оператор <-. Например, value := <-ch извлечет данные из канала ch и присвоит их переменной value.

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

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

   result := <-ch
   fmt.Println("Received:", result)

При получении данных из канала, можно проверить, был ли канал закрыт. Это делается с помощью второго возвращаемого значения в операции чтения: value, ok := <-ch. Если ok равно false, канал был закрыт.

Это полезно, когда вы работаете с несколькими Goroutines, отправляющими данные в один канал, и нужно знать, когда все Goroutines завершили свою работу:

   for {
       result, ok := <-ch
       if !ok {
           break
       }
       fmt.Println("Received:", result)
   }

Ключевая особенность работы с каналами в Go — возможность использования оператора select для мультиплексирования операций отправки и приема по нескольким каналам. Это позволяет Goroutine ожидать на нескольких каналах одновременно, продолжая выполнение, как только одна из операций становится возможной.

   select {
   case result := <-ch1:
       fmt.Println("Received from ch1:", result)
   case ch2 <- 42:
       fmt.Println("Sent 42 to ch2")
   case <-time.After(1 * time.Second):
       fmt.Println("Timeout")
   }

Типы каналов


Небуферизированные каналы

Когда Goroutine отправляет данные в небуферизированный канал, она блокируется до тех пор, пока другая Goroutine не прочитает эти данные. Аналогично, если Goroutine пытается прочитать данные из канала, она будет блокирована, пока другая Goroutine не отправит данные.

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

Создание: ch := make(chan int) — создает небуферизированный канал для передачи целых чисел.

Буферизированные каналы

Буферизированный канал имеет внутренний буфер, позволяющий отправлять данные в канал без блокировки до тех пор, пока буфер не заполнится. Отправка в полностью заполненный буферизированный канал блокирует Goroutine, пока в канале не появится свободное место.

Идеален для случаев, когда данные производятся и потребляются разными темпами.

Создание: ch := make(chan int, 100) — создает буферизированный канал для передачи целых чисел с буфером на 100 элементов.

Функция close(ch) используется для закрытия канала. После закрытия канала нельзя отправлять в него данные, но можно продолжать читать данные, которые были в нем до закрытия.

select позволяет ожидать несколько операций с каналами одновременно, блокируясь до тех пор, пока одна из операций не станет выполнимой.
Используется для реализации тайм-аутов, обработки событий от нескольких каналов и других сложных сценариев взаимодействия между Goroutines.

Пример кода с select:

  select {
  case msg := <-ch1:
      fmt.Println("Received from ch1:", msg)
  case ch2 <- 42:
      fmt.Println("Sent 42 to ch2")
  case <-time.After(1 * time.Second):
      fmt.Println("Timeout")
  }


Паттерны использования каналов


Паттерны помогают организовать эффективное взаимодействие между Goroutines, обеспечивая синхронизацию, безопасность данных и четкую структуру кода. Некоторые ключевые паттерны использования каналов в Go:

1. Fan-out и Fan-in

Fan-out

Распределение задач между несколькими Goroutines для параллельной обработки. Подходит для сценариев, где задачи могут выполняться независимо и параллельно, например, обработка запросов к базе данных или выполнение вычислений.
Технический представляет собой отправку задачи в несколько Goroutines, каждая из которых читает из общего канала.

Fan-in

Собирает результаты из нескольких Goroutines в один канал. Полезно для агрегации результатов параллельно выполняемых задач.
Несколько Goroutines отправляют свои результаты в один канал, который затем читается для получения окончательных данных.

Пайплайн (Pipeline)

Серия обработчиков, где каждый этап принимает входные данные, обрабатывает их и передает результаты на следующий этап. Используется для обработки потоков данных, где каждый этап добавляет или изменяет данные.
Каждый этап пайплайна реализован как Goroutine, читающая из входного канала и пишущая в выходной.
Cоздадим пайплайн из трех этапов, каждый из которых представлен отдельной Goroutine. Каждый этап принимает данные, обрабатывает их и передает результат на следующий этап:

package main

import (
    "fmt"
    "strconv"
)

func generator(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        for _, n := range nums {
            out <- n
        }
        close(out)
    }()
    return out
}

func square(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        for n := range in {
            out <- n * n
        }
        close(out)
    }()
    return out
}

func toString(in <-chan int) <-chan string {
    out := make(chan string)
    go func() {
        for n := range in {
            out <- strconv.Itoa(n)
        }
        close(out)
    }()
    return out
}

func main() {
    gen := generator(2, 3, 4)
    sq := square(gen)
    str := toString(sq)

    for s := range str {
        fmt.Println(s)
    }
}


В этом примере generator функция генерирует числа, square функция возводит их в квадрат, а toString преобразует числа в строки. Данные передаются через каналы между этапами.

Контроль времени ожидания (Timeout сontrol)

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

package main

import (
    "fmt"
    "time"
)

func operation(ch chan<- string) {
    // Имитация длительной операции
    time.Sleep(2 * time.Second)
    ch <- "результат операции"
}

func main() {
    ch := make(chan string)
    go operation(ch)

    select {
    case res := <-ch:
        fmt.Println(res)
    case <-time.After(1 * time.Second):
        fmt.Println("Тайм-аут операции")
    }
}


В этом примере operation функция имитирует длительную операцию. Основная Goroutine ожидает результат или тайм-аут в течение одной секунды с использованием select. Если операция не завершается в течение одной секунды, срабатывает ветка тайм-аута.

Оркестрация с помощью select

Управление несколькими каналами одновременно. Подходит для ситуаций, когда Goroutine должна обрабатывать несколько входных каналов.
Реализуется использованием select для одновременного ожидания на нескольких каналах, обработки событий, поступающих из этих каналов.

Пример ожидания сообщений из нескольких каналов:

package main

import (
    "fmt"
    "time"
)

func worker(id int, ch chan string) {
    for {
        time.Sleep(time.Second)
        ch <- fmt.Sprintf("worker %d: завершил задачу", id)
    }
}

func main() {
    ch1 := make(chan string)
    ch2 := make(chan string)

    go worker(1, ch1)
    go worker(2, ch2)

    for i := 0; i < 5; i++ {
        select {
        case msg1 := <-ch1:
            fmt.Println(msg1)
        case msg2 := <-ch2:
            fmt.Println(msg2)
        }
    }
}


В этом примере две Goroutines (worker(1, ch1) и worker(2, ch2)) отправляют сообщения в свои каналы. Основная Goroutine использует select для обработки сообщений из обоих каналов.

Завершение работы

Позволяет одной Goroutine сообщить другой о необходимости завершить работу. Останавливает Goroutines, когда они больше не нужны, предотвращая утечки ресурсов.
Реализуется так же использованием канала для отправки сигнала о завершении, обычно в сочетании с select.
Как использовать select в сочетании с каналом для управления завершением работы Goroutine:

package main

import (
    "fmt"
    "time"
)

func worker(stopCh chan struct{}) {
    for {
        select {
        case <-stopCh:
            fmt.Println("worker: получен сигнал о завершении, завершаю работу")
            return
        default:
            // Выполнение обычной работы
            fmt.Println("worker: работаю")
            time.Sleep(time.Second)
        }
    }
}

func main() {
    stopCh := make(chan struct{})
    go worker(stopCh)

    // Предположим, что основная Goroutine занимается своими задачами
    time.Sleep(3 * time.Second)

    // Отправляем сигнал о завершении работе
    close(stopCh)

    // Даем время для завершения работы
    time.Sleep(time.Second)
}


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

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

Синхронизация данных


Синхронизация данных относится к процессу координации доступа к данным между несколькими потоками (или Goroutines в случае Go), чтобы предотвратить конфликты и неконсистентность данных.

1. Мьютексы (Mutexes)
Мьютекс используется для обеспечения взаимного исключения доступа к общим ресурсам, тем самым предотвращая одновременное изменение данных несколькими Goroutines.
Реализация в Go
В стандартной библиотеке sync Go предоставляется тип Mutex, который используется для создания мьютекса.

Блокировка и разблокировка:

var mu sync.Mutex
mu.Lock()   // Блокировка мьютекса перед доступом к общим данным
// Критический раздел: здесь происходит работа с общими данными
mu.Unlock() // Разблокировка мьютекса после завершения работы

Пример применения:

var counter int
var mu sync.Mutex

func increment() {
    mu.Lock()
    counter++ // безопасный доступ к общей переменной
    mu.Unlock()
}

func main() {
    for i := 0; i < 10; i++ {
        go increment()
    }
    // дополнительный код для ожидания завершения Goroutines


}
2. Read-Write Mutexes
Read-Write Mutex (представленный как sync.RWMutex) позволяет множественный доступ для чтения, но исключительный доступ для записи.

Множество Goroutines могут одновременно захватывать мьютекс в режиме чтения. Только одна Goroutine может захватывать мьютекс в режиме записи, и при этом должна быть гарантирована полная исключительность (никакие другие Goroutines не могут читать или писать).

Пример использования:

import "sync"

var rw sync.RWMutex
var sharedResource int

func readOperation() {
    rw.RLock() // Захват мьютекса для чтения
    _ = sharedResource // Чтение общего ресурса
    rw.RUnlock() // Освобождение мьютекса после чтения
}

func writeOperation() {
    rw.Lock() // Захват мьютекса для записи
    sharedResource++ // Модификация общего ресурса
    rw.Unlock() // Освобождение мьютекса после записи
}


В этом примере rw используется для управления доступом к sharedResource. Операция чтения не блокирует другие операции чтения, но операция записи требует полной исключительности.

1. Каналы

Каналы — это не только средство обмена данными, но и инструмент для синхронизации Goroutines.
С помощью каналов можно реализовать различные паттерны синхронизации, включая ограничение времени выполнения и обработку событий.

   ch := make(chan int)
   go func() {
       // выполнение задачи
       ch <- result
   }()

2. WaitGroups

sync.WaitGroup используется для ожидания завершения группы Goroutines. Полезно для сценариев, где нужно дождаться завершения всех запущенных Goroutines, прежде чем продолжить выполнение программы.
Каждая Goroutine при запуске вызывает wg.Add(1), а по завершении — wg.Done(). Главная Goroutine использует wg.Wait() для ожидания завершения всех Goroutines.

   var wg sync.WaitGroup
   wg.Add(1)
   go func() {
       defer wg.Done()
       // выполняемая задача
   }()
   wg.Wait()

3. Атомарные операции

Атомарные операции позволяют выполнять операции над общими данными без необходимости использования мьютексов. Часто используются для простых операций, таких как инкрементирование счетчика, где мьютекс может быть избыточным.
В Go атомарные операции реализованы в пакете sync/atomic.

   var counter int32
   atomic.AddInt32(&counter, 1)

5 распространенных ошибок


1. Утечка Goroutines

Неуправляемое создание Goroutines может привести к их утечке, что означает, что Goroutines продолжают существовать, даже когда они больше не нужны, что приводит к избыточному использованию ресурсов.

Решение:
Используйте контекст (`context.Context`) для контроля жизненного цикла Goroutines. Аккуратно управляйте условиями завершения Goroutines, используя каналы или другие механизмы синхронизации.

2. Заблокированные goroutines

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

Решение:
Используйте паттерны с тайм-аутом, например, с помощью select и time.After. Убедитесь, что каналы правильно закрываются, и другие Goroutines отправляют ожидаемые сигналы.

3. Гонки данных

Одновременный доступ к общим данным из разных Goroutines без должной синхронизации приводит к гонкам данных.

Решение:
Используйте мьютексы (sync.Mutex) или каналы для безопасного доступа к общим данным. Применяйте паттерн «один писатель, множество читателей» при использовании каналов.

4. Мертвые замки (Deadlocks)

Программа полностью «зависает», потому что две или более Goroutines ожидают друг от друга действий, которые никогда не произойдут.

Решение:
Тщательно проектируйте логику синхронизации и избегайте ситуаций, когда Goroutine ожидает ресурс, захваченный другой Goroutine. Используйте инструменты анализа кода для обнаружения потенциальных мертвых замков.

5. Неправильное использование закрытия каналов

Попытка записать в закрытый канал или закрытие канала более одного раза вызывает панику в программе.

Решение:
Убедитесь, что канал закрывается только один раз и только отправляющей стороной. Перед отправкой в канал проверяйте, не закрыт ли он уже, особенно в условиях конкурентного доступа.

Заключение


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

Использование Goroutines и каналов позволяет добиться высокой производительности, но также важно сохранять читаемость и поддерживаемость кода.

© Habrahabr.ru