Язык программирования Go от Google включает в себя поддержку многоядерного асинхронного программирования, функционального программирования и механизма замыканий. Насколько удачно реализованы эти функции в Go по сравнению с другими современными языками? Попробуем разобраться.
В январе 2010 г. я написал статью для eWEEK, посвященную языку Go от Google. В той статье я рассматривал, главным образом, синтаксис нового языка. С тех пор язык менялся, эволюционировал, что в конечном счете привело к долгожданному выходу 28 марта релиза 1.0, а 24-го сентября релиза 1.0.3.
После этого было выпущено несколько малых обновлений с целью устранения незначительных ошибок, и сейчас, по-видимому, настало время взглянуть более внимательно на окончательную реализацию языка.
Go и многоядерное программирование
Первое, на что я обратил пристальное внимание, был параллелизм вычислений. Современные веб-решения должны уметь обслуживать большое число пользователей одновременно. В этих условиях использование параллельных вычислений превращается в обязательное требование. Чтобы протестировать параллелизм в Google Go, я использовал компьютер с процессором Inter Core i7 второго поколения с четырьмя ядрами, каждое с двумя потоками, всего получилось восемь виртуальных процессоров. Поиск в Google информации по проблемам с параллельными потоками в Go выдал мне несколько ссылок с жалобами на замедление работы программ. Я решил внимательно разобраться в ситуациях, вызвавших нарекания разработчиков.
Следует помнить, что параллельное программирование — достаточно сложная технология, так как в большинстве случаев, недостаточно просто увеличить число процессоров и рассчитывать, что все будет работать само собой. Необходимо тщательно обдумать алгоритм программы, чтобы она могла работать в среде параллельных вычислений. И если программа неправильно спроектирована, вы не увидите большого прироста в скорости, и даже наоборот, работа программы может замедлиться. Различные части алгоритма могут начать исполняться не параллельно, а последовательно, и вся программа в целом будет работать медленнее.
Для обеспечения параллельных вычислений, в языке Go существуют конструкции, которые Google называет “гоу-процессы” (goroutines), похожие на то, что в других языках называется “сопроцессы” (coroutines). Однако важная отличительная особенность гоу-процессов, состоит в том, что они не поддерживают функции передачи управления и возобновления работы (yield и resume). Гоу-процессы могут легко обмениваться информацией через особые переменные, объединенные в “каналы” (channels).
В других языках также можно реализовать обмен информацией между процессами, но Go предоставляет очень простой механизм для такого обмена. Однако для обеспечения работы этого механизма нужно будет написать дополнительные части кода. Каналы обеспечивают эффективный способ обмена информацией через разделяемые переменные, причем синхронизация процессов уже встроена в язык. И в этом содержится источник потенциальной проблемы: скажем, если у вас есть один канал для обмена переменными и восемь одновременно исполняемых потоков (каждый на своем ядре), то при обращении к каналу из одного из потоков, остальные будут вынуждены перейти в режим ожидания. При неправильном проектировании, эта особенность может полностью дискредитировать саму идею параллельных вычислений.
Рассмотрим данную проблему на практическом примере. Для понимания примера важно знать принципы работы алгоритмов с параллельными вычислениями, а так же базовые концепции параллелизма, такие как “сокращение” (reduction).
Используя гоу-процесс, мы можем легко написать цикл, порождающий параллельные процессы. Если вы сталкивались с расширением C++ под названием Cilk Plus, вы заметите, что конструкция напоминает цикл Cilk_for. Однако, в Cilk Plus вы просто пишете цикл, как если бы это был обычный цикл в C++, а исполняемая среда сама решает, в какой момент и как использовать параллельность вычислений. В синтаксисе Go нет оператора для параллельного цикла, но есть средства, позволяющие разнести вычисление на несколько параллельных потоков, например так:
for i := 0; i<30; i++ {
go func(i2 int) {
...
}(i)
}
Однако таким образом потоки не запустятся автоматически на разных ядрах. Можно включить поддержку нескольких ядер просто добавив одну строку:
runtime.GOMAXPROCS(8)
Это команда исполняемой среде Go начать использовать 8 логических ядер. Когда я отлаживал этот фрагмент, я вписал тестовый цикл внутрь вызываемой функции, и, как и ожидалось, все восемь ядер на моей машине оказались загружены (чтобы отследить это, я запускаю Task manager под Windows и вижу нагрузку на каждом из ядер). Число ядер можно задать функцией runtime.NumCPU():
runtime.GOMAXPROCS(runtime.NumCPU())
Надо заметить, что поведение исполняемой среды отличается от того же Cilk Plus. В документации по GOMAXPROCS читаем: “Данная функция будет упразднена, когда работа планировщика будет усовершенствована”. Это наводит на мысль, что Google планирует автоматизировать работу планировщика с тем, чтобы программисту не требовалась задавать в явном виде число ядер, как это уже сделано в Cilk Plus.
И конечно, задавая вручную число ядер, можно спровоцировать проблему.
Например, вы установили функцией GOMAXPROCS число процессоров равным восьми, а затем пишите цикл, который запускает параллельно, скажем, 60 гоу-процессов. Планировщик запускает сразу 8 из них, а затем сгенерирует 8 вызовов на приостановку процессов до момента освобождения одного из ядер. Другими словами, запросы на приостановку синхронизируются. Посмотрим на код более внимательно:
runtime.GOMAXPROCS(8)
for i := 0; i<8; i++ {
go func(c2 int) {
fmt.Println("starting", c2)
x := 0
for j := 0; j<1000000000; j++ {
x += 1
x -= 1
}
fmt.Println(c2)
}(i)
}
fmt.Println("Finished with for loop")
В этом примере, если задать 8 виртуальных процессоров, то сообщение “Цикл закончен” (Finished with for loop) появится сразу же. Программа породит восемь потоков, завершая на этом цикл, а сообщение будет выведено на экран в то время, пока потоки будут еще работать. Но стоит поменять значение счетчика цикла на 9, и поведение программы изменится. Девятый поток встанет на паузу и будет ожидать свободного процесса. Только когда такой процесс появится, поток отработает и программа закончит работу.
Хорошо. Интересно, что изменится, если мы немного изменим код и вставим оператор Sleep во внутренний цикл.
Как и в большинстве языков, Sleep освобождает поток с тем, чтобы другие процессы могли им воспользоваться. Это должно навести меня на мысль, что же тут происходит. Когда я убираю оператор Sleep и вместо него меняю параметр GOMAXPROCS(7), программа начинает работать быстрее. Почему? Потому, что вызывающей функции для корректной работы тоже нужен один поток. Но выделив ровно 8 потоков и процессоров на наш цикл, я добился того, что вызывающая функция не может сохранять асинхронность относительно остальных процессов.
При уменьшении числа на единицу, программа начинает работать лучше и вызывающая функция перестает зависать. Далее, я еще раз меняю код и ставлю счетчик цикла равным 64. Планировщик запускает 60 потоков и все процессы остаются асинхронными, давая возможность семи первым процессам отработать на семи процессорах. Как только очередной процесс финишировал, планировщик ставил следующий поток в очередь ожидания. Строка “Цикл закончен” появилась практически сразу же, как и ожидалось, что означат, что вызывающая функция продолжала нормально работать.
Мораль сей басни такова — планировщик все еще нуждается в доработке, и к счастью, Google, похоже, полностью отдает себе в этом отчет. Пока же нужно соблюдать осторожность в работе с параллельными процессами и быть готовым к неожиданным эффектам при изменении параметра GOMAXPROCS.
Функциональное программирование и замыкания
Еще один момент, которого я не касался в прошлой статье связан с замыканиями. Замыкания — мощная особенность динамических языков, таких как JavaScript, но и с ними можно наступить на грабли. В Google Go если у вас есть функция, которая порождает несколько процессов, и эта функция заканчивает работу раньше, чем порожденные ею процессы, значения ее переменных не обнуляются, а остаются неизменными, пока все процессы не закончат работу. Я проверил это — все подтвердилось.
Для проверки я взял код, приведенный в статье выше, и переместил его из основной в его собственную функцию. Я создал несколько переменных для этой функции и заставил каждый поток, порожденный в цикле распечатывать эти переменные. Распечатка переменных продолжалась даже после того, как произошел возврат из функции.
Что же касается функционального программирования с его требованием, чтобы функции являлись объектами первого класса, в Go это требование выполняется в полной мере. Я весьма просто проверил и это. Однако в отличие от, например, JavaScript, вам нужно определять пользовательский тип для функций, что роднит Go скорее с языками типа C#.
Выводы
Мне понравился Google Go когда я писал о нем в первый раз, а теперь он нравится мне еще больше. Я серьезно подумываю о том, чтобы переписать некоторые из своих веб-приложений на этом языке и разместить их на движке Google App Engine. Будет интересно посмотреть, что из этого может получиться, но пока все указывает на то, что все будет прекрасно работать. Я планирую активно использовать функции многоядерного программирования, но здесь нужно быть аккуратным, поскольку в этой области существуют определенные подводные камни. В целом синтаксис и реализация языка настолько современны и совершенны, что, я думаю, мне не придется рвать волосы на голове, осваивая новые инструменты. Мне кажется, игра стоит свеч.