time.Duration и арифметика времени в Go: Add, Sub, Since и ParseDuration

• обновлено • 6 мин чтения

В пакете time есть два разных понятия. time.Time как конкретный момент времени. time.Duration как промежуток между моментами. Форматирование и парсинг строк я уже разбирал в статье «Форматирование дат и времени в Go». Здесь сосредоточимся на другом: как складывать и вычитать время, измерять интервалы и работать с длительностями.

Это один из разборов в серии для начинающих. Общий маршрут изучения языка собран в статье «Go для начинающих: дорожная карта».

Что такое time.Duration

time.Duration внутри себя это один int64 в котором хранится количество наносекунд. Поэтому длительности можно складывать, вычитать и умножать на число как обычные числа.

package main

import (
	"fmt"
	"time"
)

func main() {
	d := 2*time.Hour + 30*time.Minute
	fmt.Println(d) // 2h30m0s

	fmt.Println(d * 2) // 5h0m0s
	fmt.Println(d / 3) // 50m0s
}

Константы time.Hour, time.Minute, time.Second, time.Millisecond и так далее — это значения Duration. Чтобы получить «3 секунды», нужно писать 3 * time.Second, а не просто 3:

timeout := 3 * time.Second // правильно
wrong := 3                 // это 3 наносекунды, а не 3 секунды

Метод String() (вызывается автоматически при печати) выводит длительность в человекочитаемом виде: 2h3m45s.

Перевод в числа

У Duration есть методы для перевода в конкретные единицы. Для разных единиц измерения тоже разные: Hours, Minutes и Seconds возвращают float64, а Milliseconds, Microseconds и Nanoseconds уже возвращают int64.

d := 90 * time.Minute

fmt.Println(d.Hours())        // 1.5      (float64)
fmt.Println(d.Minutes())      // 90       (float64)
fmt.Println(d.Milliseconds()) // 5400000  (int64)

Если нужно собрать время формата HH:MM:SS вручную, делите с остатком:

d := 1*time.Hour + 14*time.Minute + 30*time.Second
h := int(d.Hours())
m := int(d.Minutes()) % 60
s := int(d.Seconds()) % 60
fmt.Printf("%02d:%02d:%02d\n", h, m, s) // 01:14:30

Арифметика моментов

С самим time.Time работают три метода.

Add сдвигает момент на некую длительность:

now := time.Now()
later := now.Add(15 * time.Minute) // через 15 минут
before := now.Add(-1 * time.Hour)  // час назад

Sub возвращает разницу между двумя моментами как Duration:

start := time.Now()
// ... работа ...
elapsed := time.Now().Sub(start)
fmt.Println(elapsed) // 1.2s

AddDate сдвигает по календарю на годы, месяцы и дни. Это не то же самое, что прибавить 24 * time.Hour, потому что AddDate учитывает разную длину месяцев и переход на летнее время.

t := time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC)
fmt.Println(t.AddDate(0, 1, 0).Format("2006-01-02"))
// 2026-03-03

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

time.Since и time.Until

Две частые операции, «сколько прошло» и «сколько осталось», оформлены отдельными функциями. Они короче, чем ручной Sub двух дат.

start := time.Now()
doWork()
fmt.Println("заняло:", time.Since(start)) // равно time.Now().Sub(start)

deadline := time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC)
fmt.Println("осталось:", time.Until(deadline)) // равно deadline.Sub(time.Now())

time.Since(start) часто используют, чтобы замерить, сколько работал участок кода.

Монотонные часы

time.Now() возвращает не одно значение, а два. Внутри time.Time лежат настенные часы и монотонные. Настенные могут прыгнуть назад после синхронизации по NTP или при переводе часов. Монотонные идут только вперёд.

Sub, Since и Until используют монотонные часы, если они есть у обоих моментов. Поэтому замер длительности не сломается от скачка системного времени.

start := time.Now()
doWork()
fmt.Println(time.Since(start)) // монотонный замер, NTP не помешает

Монотонная часть теряется при любой операции, которая создаёт новое время из компонентов. Это Round, Truncate, UTC, In, AddDate, а также сериализация в JSON и обратно. После них останутся только настенные часы.

start := time.Now()
saved := start.Round(0) // Round(0) отбрасывает монотонные часы явно

Это же причина, по которой моменты нельзя сравнивать через ==. Оператор сравнивает структуру целиком, вместе с указателем на часовой пояс и монотонной частью.

Парсинг длительностей: ParseDuration

Если длительность приходит строкой (например, из конфига или флага командной строки), её разбирает time.ParseDuration:

d, err := time.ParseDuration("1h30m")
if err != nil {
	// обработка ошибки
}
fmt.Println(d) // 1h30m0s

Допустимые единицы: ns, us (или µs), ms, s, m, h. Единицы для дней нет — "1d" приведёт к ошибке:

_, err := time.ParseDuration("1d")
fmt.Println(err)
// time: unknown unit "d" in duration "1d"

Для суток пишите "24h". Строку можно делать дробной и знаковой: "-1.5h", "300ms", "2h45m".

Округление длительностей

Round округляет к ближайшему кратному, Truncate отбрасывает остаток вниз:

d := 1*time.Hour + 14*time.Minute + 30*time.Second

fmt.Println(d.Round(time.Hour))    // 1h0m0s
fmt.Println(d.Truncate(time.Hour)) // 1h0m0s
fmt.Println(d.Round(time.Minute))  // 1h15m0s (30s округлились вверх)

Те же методы есть и у time.Time. Они удобны, когда нужно «обнулить» секунды или привести момент к началу часа.

Сравнение моментов

Для сравнений time.Time есть методы Before, After и Equal. Оператором == сравнивать моменты не стоит по причинам из предыдущего раздела.

if deadline.Before(time.Now()) {
	fmt.Println("дедлайн прошёл")
}

// для проверки равенства момента — Equal, не ==
if t1.Equal(t2) {
	fmt.Println("один и тот же момент")
}

Если нужен результат в виде числа, подойдёт Compare. Он возвращает -1, 0 или 1 и удобен для slices.SortFunc:

slices.SortFunc(events, func(a, b Event) int {
	return a.At.Compare(b.At)
})

У Duration есть Abs для модуля. Разница моментов бывает отрицательной, если порядок аргументов неизвестен заранее:

diff := a.Sub(b).Abs()
if diff > time.Minute {
	fmt.Println("расхождение больше минуты")
}

Что изменилось к Go 1.27

Хорошая новость для тех, кто учит язык: арифметика времени не меняется годами. Add, Sub, Since, ParseDuration и округления работают ровно так же, как и в старых версиях. Всё, что выше, проверено на Go 1.27.

Одно изменение всё же есть, и касается оно таймеров. В Go 1.23 у Timer и Ticker убрали буфер в канале и заодно разрешили сборщику мусора собирать таймер, на который никто не ссылается. Старое поведение можно было вернуть через GODEBUG=asynctimerchan=1. В Go 1.27 эту настройку убрали окончательно. Каналы из пакета time теперь всегда небуферизованные.

На практике это значит, что тик, который никто не прочитал вовремя, больше не лежит в канале. Код вида «остановил тикер, потом дочитал канал» теперь может зависнуть:

ticker := time.NewTicker(time.Second)
defer ticker.Stop()

for {
	select {
	case <-ticker.C:
		doWork()
	case <-ctx.Done():
		return
	}
}

Такой цикл корректен и с новым поведением. А вот <-ticker.C после ticker.Stop() блокируется навсегда, потому что запасённого значения в канале нет.

Заключение

Две главные составляющие пакета time это момент времени (time.Time) и промежуток (time.Duration). Длительность под капотом представляет собой int64 наносекунд, поэтому её можно складывать и умножать как число, но константы вроде time.Second обязательны для использования: «голое» число 3 выразится как три наносекунды.

Для большинства работы хватает:

  1. Add и Sub — сдвиг момента и разница между моментами.
  2. AddDate — календарный сдвиг с учётом длины месяцев.
  3. time.Since и time.Until — «сколько прошло» и «сколько осталось».
  4. ParseDuration — разбор строк вроде "1h30m" (без единицы для дней).
  5. Before, After, Equal и Compare — сравнение моментов времени вместо ==.

Форматирование и парсинг дат и времени из строк — отдельная тема, она разобрана в статье про reference time 2006-01-02.


Об авторе: Александр Бруяко — руководитель разработки с опытом в backend, инфраструктуре и управлении командами.


Теги: