2023年4月

前言

Disruptor的高性能,是多种技术结合以及本身架构的结果。本文主要讲源码,涉及到的相关知识点需要读者自行去了解,以下列出:

  • 锁和CAS
  • 伪共享和缓存行
  • volatile和内存屏障

原理

此节结合demo来看更容易理解:传送门

下图来自官方文档

Untitled

官方原图有点乱,我翻译一下

Untitled

在讲原理前,先了解 Disruptor 定义的术语

  • Event

    存放数据的单位,对应 demo 中的
    LongEvent

  • Ring Buffer

    环形数据缓冲区:这是一个首尾相接的环,用于存放 Event ,用于生产者往其存入数据和消费者从其拉取数据

  • Sequence

    序列:用于跟踪进度(生产进度、消费进度)

  • Sequencer

    Disruptor的核心,用于在生产者和消费者之间传递数据,有单生产者和多生产者两种实现。

  • Sequence Barrier

    序列屏障,消费者之间的依赖关系就靠序列屏障实现

  • Wait Strategy

  • 等待策略,消费者等待生产者将发布的策略

  • Event Processor

    事件处理器,循环从 RingBuffer 获取 Event 并执行 EventHandler。

  • Event Handler

    事件处理程序,也就是消费者

  • Producer

    生产者

Ring Buffer

环形数据缓冲区(RingBuffer),逻辑上是首尾相接的环,在代码中用数组来表示
Object[]
。Disruptor生产者发布分两步

  • 步骤一:申请写入 n 个元素,如果可以写入,这返回最大序列号
  • 步骤二:根据序列号去 RingBuffer 中获取 Event,修改并发布
RingBuffer<LongEvent> ringBuffer = disruptor.getRingBuffer();
// 获取下一个可用位置的下标(步骤1)
long sequence = ringBuffer.next();
try {
    // 返回可用位置的元素
    LongEvent event = ringBuffer.get(sequence);
    // 设置该位置元素的值
    event.set(l);
} finally {
    // 发布
    ringBuffer.publish(sequence);
}

这两个步骤由 Sequencer 完成,分为单生产者和多生产者实现

Sequencer

单生产者

如果申请 2 个元素,则如下图所示(圆表示 RingBuffer)

// 一般不会有以下写法,这里为了讲解源码才使用next(2)
// 向RingBuffer申请两个元素
long sequence = ringBuffer.next(2);
for (long i =  sequence-1; i <= sequence; i++) {
    try {
        // 返回可用位置的元素
        LongEvent event = ringBuffer.get(i);
        // 设置该位置元素的值
        event.set(1);
    } finally {
        ringBuffer.publish(i);
    }
}

Untitled

next 申请成功的序列,cursor 消费者最大可用序列,gatingSequence 表示能申请的最大序列号。红色表示待发布,绿色表示已发布。
申请相当于占位置,发布需要一个一个按顺序发布

如果 RingBuffer 满了呢,在上图步骤二的基础上,生产者发布了3个元素,消费者消费1个。此时生产者再申请 2个元素,就会变成下图所示

Untitled

只剩下 1 个空间,但是要申请 2个元素,此时程序会自旋等待空间足够。

接下来结合代码看,单生产者的 Sequencer 实现为 SingleProducerSequencer,先看看构造方法

abstract class SingleProducerSequencerPad extends AbstractSequencer
{
    protected long p1, p2, p3, p4, p5, p6, p7;

    SingleProducerSequencerPad(int bufferSize, WaitStrategy waitStrategy)
    {
        super(bufferSize, waitStrategy);
    }
}

abstract class SingleProducerSequencerFields extends SingleProducerSequencerPad
{
    SingleProducerSequencerFields(int bufferSize, WaitStrategy waitStrategy)
    {
        super(bufferSize, waitStrategy);
    }

    long nextValue = Sequence.INITIAL_VALUE;
    long cachedValue = Sequence.INITIAL_VALUE;
}

public final class SingleProducerSequencer extends SingleProducerSequencerFields
{
    protected long p1, p2, p3, p4, p5, p6, p7;

    public SingleProducerSequencer(int bufferSize, WaitStrategy waitStrategy)
    {
        super(bufferSize, waitStrategy);
    }
}

这是 Disruptor 高性能的技巧之一,SingleProducerSequencer 需要的类变量只有 nextValue 和cachedValue,p1 ~ p7 的作用是填充缓存行,这能保证 nextValue 和cachedValue 必定在独立的缓存行,我们可以用
ClassLayout
打印内存布局看看

Untitled

接下来看如何获取序列号(也就是步骤一)

// 调用路径
// RingBuffer#next()
// SingleProducerSequencer#next()
public long next(int n)
{
    if (n < 1)
    {
        throw new IllegalArgumentException("n must be > 0");
    }

    long nextValue = this.nextValue;

    //生产者当前序号值+期望获取的序号数量后达到的序号值
    long nextSequence = nextValue + n;
    //减掉RingBuffer的总的buffer值,用于判断是否出现‘覆盖’
    long wrapPoint = nextSequence - bufferSize;
    //从后面代码分析可得:cachedValue就是缓存的消费者中最小序号值,他不是当前最新的‘消费者中最小序号值’,而是上次程序进入到下面的if判定代码段时,被赋值的当时的‘消费者中最小序号值’
    //这样做的好处在于:在判定是否出现覆盖的时候,不用每次都调用getMininumSequence计算‘消费者中的最小序号值’,从而节约开销。只要确保当生产者的节奏大于了缓存的cachedGateingSequence一个bufferSize时,从新获取一下 getMinimumSequence()即可。
    long cachedGatingSequence = this.cachedValue;

    //(wrapPoint > cachedGatingSequence) : 当生产者已经超过上一次缓存的‘消费者中最小序号值’(cachedGatingSequence)一个‘Ring’大小(bufferSize),需要重新获取cachedGatingSequence,避免当生产者一直在生产,但是消费者不再消费的情况下,出现‘覆盖’
    //(cachedGatingSequence > nextValue) : https://github.com/LMAX-Exchange/disruptor/issues/76
    // 这里判断就是生产者生产的填满BingBUffer,需要等待消费者消费
    if (wrapPoint > cachedGatingSequence || cachedGatingSequence > nextValue)
    {
        cursor.setVolatile(nextValue);  // StoreLoad fence

        //gatingSequences就是消费者队列末尾的序列,也就是消费者消费到哪里了
        //实际上就是获得处理的队尾,如果队尾是current的话,说明所有的消费者都执行完成任务在等待新的事件了
        long minSequence;
        while (wrapPoint > (minSequence = Util.getMinimumSequence(gatingSequences, nextValue)))
        {
            // 等待1纳秒
            LockSupport.parkNanos(1L); // TODO: Use waitStrategy to spin?
        }

        this.cachedValue = minSequence;
    }

    this.nextValue = nextSequence;

    return nextSequence;
}

public void publish(long sequence)
{
    // 更新序列号
    cursor.set(sequence);
    // 等待策略的唤醒
    waitStrategy.signalAllWhenBlocking();
}

要解释的都在注释里了,gatingSequences 是消费者队列末尾的序列,对应着就是下图中的 ApplicationConsumer 的 Sequence

Untitled

多生产者

看完单生产者版,接下来看多生产者的实现。因为是多生产者,需要考虑并发的情况。

如果有A、B两个消费者都来申请 2 个元素

Untitled

cursor 申请成功的序列,HPS 消费者最大可用序列,gatingSequence 表示能申请的最大序列号。红色表示待发布,绿色表示已发布。HPS 是我自己编的缩写,表示
getHighestPublishedSequence
方法的返回值

如图所示,只要申请成功,就移动 cursor 的位置。RingBuffer 并没有记录发布情况(图中的红绿颜色),这个发布情况由
MultiProducerSequencer

availableBuffer
来维护。

下面看代码

public final class MultiProducerSequencer extends AbstractSequencer
{
    // 缓存的消费者中最小序号值,相当于SingleProducerSequencerFields的cachedValue
    private final Sequence gatingSequenceCache = new Sequence(Sequencer.INITIAL_CURSOR_VALUE);
    // 标记元素是否可用
    private final int[] availableBuffer;

    public long next(int n)
    {
        if (n < 1)
        {
            throw new IllegalArgumentException("n must be > 0");
        }

        long current;
        long next;

        do
        {
            current = cursor.get();
            next = current + n;

            //减掉RingBuffer的总的buffer值,用于判断是否出现‘覆盖’
            long wrapPoint = next - bufferSize;
            //从后面代码分析可得:cachedValue就是缓存的消费者中最小序号值,他不是当前最新的‘消费者中最小序号值’,而是上次程序进入到下面的if判定代码段时,被赋值的当时的‘消费者中最小序号值’
            //这样做的好处在于:在判定是否出现覆盖的时候,不用每次都调用getMininumSequence计算‘消费者中的最小序号值’,从而节约开销。只要确保当生产者的节奏大于了缓存的cachedGateingSequence一个bufferSize时,从新获取一下 getMinimumSequence()即可。
            long cachedGatingSequence = gatingSequenceCache.get();

            //(wrapPoint > cachedGatingSequence) : 当生产者已经超过上一次缓存的‘消费者中最小序号值’(cachedGatingSequence)一个‘Ring’大小(bufferSize),需要重新获取cachedGatingSequence,避免当生产者一直在生产,但是消费者不再消费的情况下,出现‘覆盖’
            //(cachedGatingSequence > nextValue) : https://github.com/LMAX-Exchange/disruptor/issues/76
            // 这里判断就是生产者生产的填满BingBUffer,需要等待消费者消费
            if (wrapPoint > cachedGatingSequence || cachedGatingSequence > current)
            {
                long gatingSequence = Util.getMinimumSequence(gatingSequences, current);

                if (wrapPoint > gatingSequence)
                {
                    LockSupport.parkNanos(1); // TODO, should we spin based on the wait strategy?
                    continue;
                }

                gatingSequenceCache.set(gatingSequence);
            }
            // 使用cas保证只有一个生产者能拿到next
            else if (cursor.compareAndSet(current, next))
            {
                break;
            }
        }
        while (true);

        return next;
    }
......
}

MultiProducerSequencer

SingleProducerSequencer
的 next()方法逻辑大致一样,只是多了CAS的步骤来保证并发的正确性。接着看发布方法

public void publish(final long sequence)
{
    // 记录发布情况
    setAvailable(sequence);
    // 等待策略的唤醒
    waitStrategy.signalAllWhenBlocking();
}

private void setAvailable(final long sequence)
{
    // calculateIndex(sequence):获取序号
    // calculateAvailabilityFlag(sequence):RingBuffer的圈数
    setAvailableBufferValue(calculateIndex(sequence), calculateAvailabilityFlag(sequence));
}

private void setAvailableBufferValue(int index, int flag)
{
    long bufferAddress = (index * SCALE) + BASE;
    UNSAFE.putOrderedInt(availableBuffer, bufferAddress, flag);
    // 上面相当于 availableBuffer[index] = flag 的高性能版
}

记录发布情况,其实相当于
availableBuffer[sequence] = 圈数
,前面说了,
availableBuffer
是用来标记元素是否可用的,如果消费者的圈数 ≠ availableBuffer中的圈数,则表示元素不可用

public boolean isAvailable(long sequence)
{
    int index = calculateIndex(sequence);
    // 计算圈数
    int flag = calculateAvailabilityFlag(sequence);
    long bufferAddress = (index * SCALE) + BASE;
    // UNSAFE.getIntVolatile(availableBuffer, bufferAddress):相当于availableBuffer[sequence] 的高性能版
    return UNSAFE.getIntVolatile(availableBuffer, bufferAddress) == flag;
}

private int calculateAvailabilityFlag(final long sequence)
{
    // 相当于 sequence % bufferSize ,但是位操作更快
    return (int) (sequence >>> indexShift);
}

isAvailable() 方法判断元素是否可用,此方法的调用堆栈看完消费者就清楚了。

消费者

本小节介绍两个方面,一是 Disruptor 的消费者如何实现依赖关系的,二是消费者如何拉取消息并消费

消费者的依赖关系实现

Untitled

我们看回这张图,每个消费者前都有一个 SequenceBarrier ,这就是消费者之间能实现依赖的关键。每个消费者都有一个 Sequence,表示自身消费的进度,如图中,ApplicationConsumer 的 SequenceBarrier 就持有 ReplicaionConsumer 和 JournalConsumer 的 Sequence,这样就能控制 ApplicationConsumer 的消费进度不超过其依赖的消费者。

下面看源码,这是 disruptor 配置消费者的代码。

EventHandler journalConsumer = xxx;
EventHandler replicaionConsumer = xxx;
EventHandler applicationConsumer = xxx;

disruptor.handleEventsWith(journalConsumer, replicaionConsumer)
        .then(applicationConsumer);

// 下面两行等同于上面这行
// disruptor.handleEventsWith(journalConsumer, replicaionConsumer);
// disruptor.after(journalConsumer, replicaionConsumer).then(applicationConsumer);

先看ReplicaionConsumer 和 JournalConsumer 的配置 disruptor.handleEventsWith(journalConsumer, replicaionConsumer)

/** 代码都在Disruptor类 **/

public final EventHandlerGroup<T> handleEventsWith(final EventHandler<? super T>... handlers)
{
    // 没有依赖的消费者就创建新的Sequence
    return createEventProcessors(new Sequence[0], handlers);
}

/**
 * 创建消费者
 * @param barrierSequences 当前消费者组的屏障序列数组,如果当前消费者组是第一组,则取一个空的序列数组;否则,barrierSequences就是上一组消费者组的序列数组
 * @param eventHandlers 事件消费逻辑的EventHandler数组
 */
EventHandlerGroup<T> createEventProcessors(
    final Sequence[] barrierSequences,
    final EventHandler<? super T>[] eventHandlers)
{
    checkNotStarted();

    // 对应此事件处理器组的序列组
    final Sequence[] processorSequences = new Sequence[eventHandlers.length];
    final SequenceBarrier barrier = ringBuffer.newBarrier(barrierSequences);

    for (int i = 0, eventHandlersLength = eventHandlers.length; i < eventHandlersLength; i++)
    {
        final EventHandler<? super T> eventHandler = eventHandlers[i];

        // 创建消费者,注意这里传入了SequenceBarrier
        final BatchEventProcessor<T> batchEventProcessor =
            new BatchEventProcessor<>(ringBuffer, barrier, eventHandler);

        if (exceptionHandler != null)
        {
            batchEventProcessor.setExceptionHandler(exceptionHandler);
        }

        consumerRepository.add(batchEventProcessor, eventHandler, barrier);
        processorSequences[i] = batchEventProcessor.getSequence();
    }

    // 每次添加完事件处理器后,更新门控序列,以便后续调用链的添加
    // 所谓门控,就是RingBuffer要知道在消费链末尾的那组消费者(也是最慢的)的进度,避免消息未消费就被写入覆盖
    updateGatingSequencesForNextInChain(barrierSequences, processorSequences);

    return new EventHandlerGroup<>(this, consumerRepository, processorSequences);
}

createEventProcessors() 方法主要做了3件事,创建消费者、保存eventHandler和消费者的映射关系、更新 gatingSequences

  • EventProcessor 是消费者
  • SequenceBarrier 是消费者屏障,保证了消费者的依赖关系
  • consumerRepository 保存了eventHandler和消费者的映射关系

gatingSequences 我们在前面说过,生产者通过 gatingSequences 知道消费者的进度,防止生产过快导致消息被覆盖,更新操作在 updateGatingSequencesForNextInChain() 方法中

// 为消费链下一组消费者,更新门控序列
// barrierSequences是上一组事件处理器组的序列(如果本次是第一次,则为空数组),本组不能超过上组序列值
// processorSequences是本次要设置的事件处理器组的序列
private void updateGatingSequencesForNextInChain(final Sequence[] barrierSequences, final Sequence[] processorSequences)
{
    if (processorSequences.length > 0)
    {
        // 将本组序列添加到Sequencer中的gatingSequences中
        ringBuffer.addGatingSequences(processorSequences);
        // 将上组消费者的序列从gatingSequences中移除
        for (final Sequence barrierSequence : barrierSequences)
        {
            ringBuffer.removeGatingSequence(barrierSequence);
        }
        // 取消标记上一组消费者为消费链末端
        consumerRepository.unMarkEventProcessorsAsEndOfChain(barrierSequences);
    }
}

让我们把视线再回到消费者的设置方法

disruptor.handleEventsWith(journalConsumer, replicaionConsumer)
        .then(applicationConsumer);

journalConsumer 和 replicaionConsumer 已经设置了,接下来是 applicationConsumer

/** 代码在EventHandlerGroup类 **/

public final EventHandlerGroup<T> then(final EventHandler<? super T>... handlers)
{
    return handleEventsWith(handlers);
}

public final EventHandlerGroup<T> handleEventsWith(final EventHandler<? super T>... handlers)
{
    return disruptor.createEventProcessors(sequences, handlers);
}

可以看到,设置 applicationConsumer 最终调用的也是 createEventProcessors() 方法,区别就在于 createEventProcessors() 方法的第一个参数,这里的 sequences 就是 journalConsumer 和 replicaionConsumer 这两个消费者的 Sequence

消费者的消费逻辑

消费者的主要消费逻辑在
EventProcessor#run()
方法中,下面以
BatchEventProcessor
举例

// BatchEventProcessor#run()
// BatchEventProcessor#processEvents()
private void processEvents()
{
    T event = null;
    long nextSequence = sequence.get() + 1L;

    while (true)
    {
        try
        {
            // 获取最大可用序列
            final long availableSequence = sequenceBarrier.waitFor(nextSequence);
            ...

            // 执行消费逻辑
            while (nextSequence <= availableSequence)
            {
                // dataProvider就是RingBuffer
                event = dataProvider.get(nextSequence);
                eventHandler.onEvent(event, nextSequence, nextSequence == availableSequence);
                nextSequence++;
            }

            sequence.set(availableSequence);
        }
        catch ()
        {
            // 异常处理
        }
    }
}

方法简洁明了,在死循环中通过 sequenceBarrier 获取最大可用序列,然后从 RingBuffer 中获取 Event 并调用 EventHandler 进行消费。重点在 sequenceBarrier.waitFor(nextSequence); 中

public long waitFor(final long sequence)
        throws AlertException, InterruptedException, TimeoutException
{
    checkAlert();
    // 获取可用的序列,这里返回的是Sequencer#next方法设置成功的可用下标,不是Sequencer#publish
    // cursorSequence:生产者的最大可用序列
    // dependentSequence:依赖的消费者的最大可用序列
    long availableSequence = waitStrategy.waitFor(sequence, cursorSequence, dependentSequence, this);

    if (availableSequence < sequence)
    {
        return availableSequence;
    }
    // 获取最大的已发布成功的序号(对于发布是否成功的校验在此方法中)
    return sequencer.getHighestPublishedSequence(sequence, availableSequence);
}

熟悉的 getHighestPublishedSequence() 方法,忘了就回去看看生产者小节。waitStrategy.waitFor() 对应着图片中的 waitFor() 。

消费者的启动

前面讲了消费者的处理逻辑,但是 BatchEventProcessor#run() 是如何被调用的呢,关键在于
disruptor.start();

// Disruptor#start()
public RingBuffer<T> start()
{
    checkOnlyStartedOnce();
    for (final ConsumerInfo consumerInfo : consumerRepository)
    {
        consumerInfo.start(executor);
    }

    return ringBuffer;
}

class EventProcessorInfo<T> implements ConsumerInfo
{
    public void start(final Executor executor)
    {
        // eventprocessor就是消费者
        executor.execute(eventprocessor);
    }
}

还记得
consumerRepository
吗,没有就往上翻翻设置消费者那里的 disruptor.handleEventsWith() 方法。

所以启动过程就是
disruptor#start() → ConsumerInfo#start() → Executor#execute() → EventProcessor#run()

课后作业:Disruptor 的消费者使用了多少线程?

总结

本文讲了 Disruptor 大体逻辑和源码,当然其高性能的秘诀不止文中描述的那些。还有不同的等待策略,Sequence 中使用
Unsafe
而不是JDK中的 Atomic 原子类等等。

这两天,Auto-GPT 爆火

https://github.com/Torantulino/Auto-GPT

它是一款让最强语言模型GPT-4能够自主完成任务的模型,让整个AI圈疯了。它的嘴大突破是,可以让AI自我提示,就是说,这个AI完全不需要咱们人类了。

只知道 Auto-GPT很神奇,很牛逼,我看了很多报道和评论,但是很少看到有成功的案例,国外倒是有极少的Demo。所以我就简单做了两个测试,现将过程和结果写出来,感兴趣的同学可以测试一些,如有成功案例可以分享一下。

部署

先拉取项目到本地,安装:

git clone https://github.com/Torantulino/Auto-GPT.git
cd Auto-GPT
pip install -r requirements.txt

配置:


.env.template
后缀改为
.env

填写号你的
OPENAI_API_KEY
记得加双引号

OpenAI API 或许地址:
https://platform.openai.com/account/api-keys
.

建议顺便把EXECUTE_LOCAL_COMMANDS改成True

image-20230414163750719

执行

然后就可以执行了

python scripts/main.py

中间需要你为AI取个名字,然后给设定任务和目标

image-20230414164043012

我想用它每天从互联网爬取5条最新的AIGC相关资讯,提取摘要,翻译成中文,最后写入本地Markdown文件。

为了让它更清楚任务,我找ChatGPT做了描述,拆解了任务,翻译成了英文。

img

惊喜是它在运行过程中会提醒配置上的错误,我手动修改配置后,它会继续尝试任务,从Bloomberg和feedspot上爬取资讯都提示了非AIGC相关,感觉它想继续努力,但我已经不抱希望了,最后终止了任务。

然后有点不死心,就把任务简化了,如下图:

image-20230414163233258

结果依然不理想,下图就是它尝试N遍之后的工作成果。

我的API时GPT3.5,或许GPT4的API效果会好些?

image-20230414164326174

总结

我在朋友圈对它的评价是:目前它还不是一个合格员工,但是自信又努力。

AutoGPT 现在很不成熟,但是一个很有前途的项目,值得追一追。

基于ChatGPT,论文写作工具

公众号接入 ChatGPT 了

国内可用 ChatGPT 客户端下载

对抗 ChatGPT,免费体验 Claude

阿里版ChatGPT,拿到邀请码了

ChatGPT 系列研究报告(50个PDF),资料微信群

可能是全网最全的速查表:Python Numpy Pandas Matplotlib 机器学习 ChatGPT

概览

开门见山先上图

界定一些术语,方便后面说明:

  • GMT:格林威治平均时,太阳每天经过位于英国伦敦郊区的皇家格林威治天文台的时间为中午 12 点,1972 年之前使用的国际标准时间,因地球在它的椭圆轨道里的运动速度不均匀,这个时刻可能和实际的太阳时相差16分钟。
  • UTC:国际标准时间,相当于本初子午线 (即经度0度) 上的平均太阳时。UTC 时间是经过平均太阳时 (以格林威治时间 GMT 为准)、地轴运动修正后的新时标以及以秒为单位的国际原子时所综合精算而成。
  • Epoch:日历时间,自国际标准时间公元 1970 年 1 月 1 日 00:00:00 以来经过的秒数。

Unix 日期时间

获取

unix 通过接口 time 将 Epoch 作为整数返回,自然的包含了日期和时间两部分:

time_t time(time_t *tloc);

其中 time_t 在 64 位系统上是 8 字节整数 (long long):

sizeof (time_t) = 8

在 32 位系统上可能是 4 字节整数,没有试。

time 例程的 tloc 参数如果不为空,则时间值也存放在由 tloc 指向的单元内。

如果想获取更精准的时间,需要借助另外的接口:

int gettimeofday(struct timeval *tv, struct timezone *tz);

时间通过参数 tv 返回:

struct timeval {
    time_t      tv_sec;     /* seconds */
    suseconds_t tv_usec;    /* microseconds */
};

除了代表 UTC 的 tv_sec 外还有代表微秒的 tv_usec,注意如果只需要精确到毫秒,需要将这个值除以 1000。

在 64 位 CentOS 上它是 8 字节整数:

sizeof (suseconds_t) = 8, sizeof (struct timeval) = 16

不过不是所有 64 位系统这个字段都是 long long,在 64 位 Darwin 上它是 4 字节整数:

sizeof (suseconds_t) = 4, sizeof (struct timeval) = 16

但最终 timeval 结构体的长度还是 16,可能是内存对齐的缘故。

tz 参数用来指定时区信息:

struct timezone {
    int tz_minuteswest;     /* minutes west of Greenwich */
    int tz_dsttime;         /* type of DST correction */
};

因为一些原因,tz 在 SUS 标准中唯一合法值是 NULL,某些平台支持使用 tz 说明时区,但完全没有可移植性,例如在 Linux 上,建议这个参数设置为 NULL:

The  use  of  the  timezone  structure  is  obsolete; the tz argument should normally be specified as NULL.  (See NOTES below.)

不为 NULL 也不会报错,但是不会修改指定参数的内容。Darwin 支持这个参数,下面是它的日常返回:

minuteswest = -480, dsttime = 0

具体可参考时区和夏时制一节。

转换

time_t 类型利于接口返回,但可读性比较差,需要将它转换为人能理解的日期和时间。

struct tm {
    int tm_sec;         /* seconds */
    int tm_min;         /* minutes */
    int tm_hour;        /* hours */
    int tm_mday;        /* day of the month */
    int tm_mon;         /* month */
    int tm_year;        /* year */
    int tm_wday;        /* day of the week */
    int tm_yday;        /* day in the year */
    int tm_isdst;       /* daylight saving time */
};

这就是 struct tm,除了年月日时分秒,还有两个字段 wday / yday  用于方便的展示当前周/年中的天数,另外 isdst 标识了是否为夏时制 (参考夏时制一节)。

int tm_sec;     /* seconds (0 - 60) */
int tm_min;     /* minutes (0 - 59) */
int tm_hour;    /* hours (0 - 23) */
int tm_mday;    /* day of month (1 - 31) */
int tm_mon;     /* month of year (0 - 11) */
int tm_year;    /* year - 1900 */
int tm_wday;    /* day of week (Sunday = 0) */
int tm_yday;    /* day of year (0 - 365) */
int tm_isdst;   /* is summer time in effect? */
char *tm_zone;  /* abbreviation of timezone name */
long tm_gmtoff; /* offset from UTC in seconds */

上面给出了各个字段的取值范围,有几个点值得注意:

  • 秒:可取值 60,这是因闰秒的原因 (参考闰秒一节)
  • 年:从 1900 开始计数
  • mday:从 1 开始计数,设置为 0 表示上月最后一天
  • wday:从 0 开始计数,以周日作为第一天,因此 1 就是表示周一,以此类推
  • isdst:
    • > 0:夏时制生效
    • = 0:夏时制不生效
    • < 0:此信息不可用
  • tm_zone 和 tm_gmtoff 两个字段是 Drawin 独有的,作用有点类似上面介绍过的 timezone,不属于 SUS 标准

如果直接用这个结构体显示给用户,经常会看到以下校正代码:

printf ("%04d/%02d/%02d %02d:%02d:%02d", 
        tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, 
        tm.tm_hour, tm.tm_min, tm.tm_sec);

对 yday 的处理类似 mon。

再复习一下开始的关系图:

将 time_t 转换为 struct tm 的是 localtime 和 gmtime,反过来是 mktime:

struct tm *gmtime(const time_t *timep);
struct tm *localtime(const time_t *timep);
time_t mktime(struct tm *tm);

localtime 和 gmttime 的区别是,前者将 Epoch 转换为本地时间 (受时区、夏时制影响)、后者将 Epoch 转换为 UTC (不受时区、夏时制影响)。

mktime 只接受本地时间作为参数、将其转换为 Epoch,注意没有 mkgmtime 这类东东。

mktime 并不使用 tm 参数的所有字段,例如 wday 和 yday 就会被忽略,isdst 参数将按如下取值进行解释:

  • > 0:启用夏时制
  • = 0:禁用夏时制
  • < 0:依据系统或环境设置自行决定是否使用夏时制

mktime 还会自动规范化 (normalize) 各个字段,例如 70 秒会被更新为 1 分 10 秒。除此之外,还有以下字段会被更新:

  • wday:赋值
  • yday:赋值
  • isdst:
    • 0:不生效
    • > 0:生效
    • 不再存在 < 0 的情况

极端的情况下,struct tm 中的每个字段都可能被修改,这也是参数 tm 没有加 const 修饰的原因。

利用 mktime 的 normalize 特性,很容易就可以求出 "N 年/月/日/时/分/秒" 前/后的时间,像下面这段代码:

#include "../apue.h" 
#include <sys/time.h> 
#include <time.h> 

void print_tm (struct tm* t)
{
  printf ("%04d-%02d-%02d %02d:%02d:%02d (week day %d) (year day %d) (daylight saving time %d)\n", 
    t->tm_year + 1900, 
    t->tm_mon + 1, 
    t->tm_mday, 
    t->tm_hour, 
    t->tm_min, 
    t->tm_sec,
    t->tm_wday == 0 ? 7 : t->tm_wday, 
    t->tm_yday + 1, 
    t->tm_isdst); 
}

int main (int argc, char *argv[])
{
  if (argc < 2)
  {
    printf ("Usage: %s [+/-] [N[Y/M/D/H/m/S/w/y]]\n", argv[0]); 
    return 0; 
  }

  int ret = 0; 
  time_t now = time (NULL); 
  printf ("sizeof (time_t) = %d, now = %ld\n", sizeof(time_t), now); 

  struct tm *tm_now = localtime (&now); 
  print_tm (tm_now); 

  int shift = 0; 
  char *endptr = 0; 
  shift = strtol (argv[1], &endptr, 10); 
  switch (*endptr)
  {
    case 'Y':
      tm_now->tm_year += shift; 
      break; 
    case 'M':
      tm_now->tm_mon += shift; 
      break; 
    case 'D':
    case 'd':
      tm_now->tm_mday += shift; 
      break; 
    case 'H':
    case 'h':
      tm_now->tm_hour += shift; 
      break; 
    case 'm':
      tm_now->tm_min += shift; 
      break; 
    case 's':
    case 'S':
      tm_now->tm_sec += shift; 
      break; 
    /* 
     * tm_wday & tm_yday is ignored normally, 
     * here just do a test !!
     */
    case 'w':
    case 'W':
      tm_now->tm_wday += shift; 
      break; 
    case 'y':
      tm_now->tm_yday += shift; 
      break; 
    default:
      printf ("unkonwn postfix %c", *endptr); 
      break; 
  }

  print_tm (tm_now); 
  time_t tick = mktime (tm_now); 
  printf ("tick = %ld\n", tick); 
  print_tm (tm_now); 
  return 0; 
}

运行时随意指定:

> ./timeshift +70s
sizeof (time_t) = 8, now = 1678544442
2023-03-11 22:20:42 (week day 6) (year day 70) (daylight saving time 0)
2023-03-11 22:20:112 (week day 6) (year day 70) (daylight saving time 0)
tick = 1678544512
2023-03-11 22:21:52 (week day 6) (year day 70) (daylight saving time 0)

观察到增加 sec 字段 70 秒后达到 112 秒,经过 mktime 规范化后变为 52 秒并向上进位 1 分钟。

这个 demo 还可以用来验证设置 wday 或 yday 没有效果,例如:

> ./timeshift +100y
sizeof (time_t) = 8, now = 1678544584
2023-03-11 22:23:04 (week day 6) (year day 70) (daylight saving time 0)
2023-03-11 22:23:04 (week day 6) (year day 170) (daylight saving time 0)
tick = 1678544584
2023-03-11 22:23:04 (week day 6) (year day 70) (daylight saving time 0)

直接被忽略了,yday 根据其它字段推导,恢复了 70 的初始值。

同时也可以验证 mday = 0 时其实是指上个月最后一天,例如:

> ./timeshift -11d
sizeof (time_t) = 8, now = 1678544711
2023-03-11 22:25:11 (week day 6) (year day 70) (daylight saving time 0)
2023-03-00 22:25:11 (week day 6) (year day 70) (daylight saving time 0)
tick = 1677594311
2023-02-28 22:25:11 (week day 2) (year day 59) (daylight saving time 0)

观察到 2023-03-00 其实是 2023-02-28。

闰秒

为了减少学习曲线,一些相对零碎的概念将在遇到的时候再行说明,闰秒就属于这种情况。在解释闰秒之前,先介绍两个新的术语:

  • TAI:原子时间,基于铯原子的能级跃迁原子秒作为时标,结合了全球 400 个所有的原子钟而得到的时间,它决定了我们每个人的钟表中时间流动的速度
  • UT:世界时间,也称天文时间,或太阳时,他的依据是地球的自转,我们用它来确定多少原子时对应于一个地球日的时间长度

在确定 TAI 起点之后,由于地球自转速度有变慢的趋势 (非常小),UT 与 TAI 之间的时差便逐年积累。为弥补这一差距,便采用跳秒 (闰秒) 的方法使 TAI 与 UT 的时刻相接近,其差不超过 1 秒,这样既保持时间尺度的均匀性,又能近似地反映地球自转的变化。一般会在每年的 6 月 30 日、12 月 31 日的最后一秒进行调整。

现在回过头来看 UTC 的定义——UTC 时间是经过平均太阳时、地轴运动修正后的新时标以及以秒为单位的国际原子时所综合精算而成——是不是加深了印象?可以认为 UTC 是参考 TAI 增加闰秒的 UT。

较早的 SUS 标准允许双闰秒,tm_sec 的取值范围是 [0-61],UTC 的正式定义不允许双闰秒,所以后来的 tm_sec 的的范围被定义为 [0-60]。

不过 gmtime / localtime / mktime 都不处理闰秒,以最近的闰秒为例,2016/12/31 23:59:60,通过 linux date 命令来验证:

> date -d "2016/12/31 23:59:59" "+%s"
1483199999
> date -d @1483200000
Sun Jan  1 00:00:00 CST 2017

先反解 2016/12/31 23:59:59 的 Epoch,将其加一秒后再通过 date 展示为直观的时间,发现并没有展示为 23:59:60,而是直接进入 2017 年。

难道是示例中的这个闰秒太"新"了?找个老一点的闰秒试试:

> date -d "1995/12/31 23:59:59" "+%s"
820425599
> date -d @820425600
Mon Jan  1 00:00:00 CST 1996

1995 年这个同样不行。使用 mktime 传递 struct tm 的方式也试了,效果一样。

特别是直接反解闰秒时,date 直接报错:

> date -d "2016/12/31 23:59:60" 
date: invalid date ‘2016/12/31 23:59:60’

上面特别强调使用 linux date,因为 mac date 有另外一套语法:

> date -j -f "%Y/%m/%d %H:%M:%S" "2016/12/31 23:59:59" "+%s"
1483199999
> date -r 1483200000 "+%Y/%m/%d %H:%M:%S"
2017/01/01 00:00:00

这一点需要注意。

来看一下闰秒的分布:

可见是完全没有规律的,
甚至没办法提前把几年之后的闰秒写到系统库里,要让库可以长久的使用,只有不去管它。

想象一下 gmtime 是如何根据 Epoch 计算时间的:

  • 首先确定年份
    • 平年按 365 天计算一年的秒数,一天固定 86400 秒
    • 如果是闰年,则按 366 天计算一年的秒数
    • 计算所给的 Epoch 经历了几个闰年,闰年就是年份能被 4 整除却不能被 100 整除
  • 再依次计算月、天、时、分、秒,其中计算月、日时仍要考虑闰月的影响

压根不可能处理闰秒这种复杂的东东,反过来看,这个接口叫 gmtime 而不是 utctime 也是有道理的。

总结一下:基于 POSIX 标准的系统不处理闰秒,不过这并不影响它的精度,因为绝大多数时间来讲,GMT 时间和 UTC 给用户展示的字符串是一致的,毕竟 GTC 多出来的闰秒被安插在了 59:59:60 这种时间位置,对后面的时间没有影响。唯一的区别在于,GTC 时间的 time_t 表示会比 GMT 多那么几十秒,除非要精确计算大跨度时间范围内的绝对时间差,才需要用到闰秒。

时区

从格林威治本初子午线起,经度每向东或者向西间隔 15°,就划分一个时区,在这个区域内,大家使用同样的标准时间。

但实际上,为了照顾到行政上的方便,常将一个国家或一个省份划在一起。所以时区并不严格按南北直线来划分,而是按自然条件来划分。

全球共分为24个标准时区,相邻时区的时间相差一个小时。全部的时区定义:
Time Zone Abbreviations – Worldwide List

中国位于东八区 (UTC+8),没有像美国那样划分多个时区,中国一整个都在一个时区:CST。

不过由于幅员辽阔,新疆乌鲁木齐实际位于东六区,早上 9 点才相当于北京早上 7 点,因此如果观察一个国内服务器早高峰,会发现新疆是最后上线的。

时区是导致同一个系统 localtime 和 gmtime 返回值有差异的主要原因。回顾一下开始的关系图:

红色表示接口会受时区影响,以 localtime 为例,man 中是这样解释它如何获取当前时区设置的:

  • TZ 环境变量,形如 Asia/Shanghai 的字符串
    • 为空:UTC
    • 解析成功:设置 tzname、timezone 等全局变量
    • 解析不成功:UTC
  • 系统时区设置
    • CentOS - /etc/localtime -> /usr/share/zoneinfo/Asia/Shanghai
    • Darwin  - /etc/localtime -> /var/db/timezone/zoneinfo/Asia/Shanghai
    • Ubuntu  - /etc/timezone: Asia/Shanghai
    • ...

TZ 环境变量

优先检查 TZ 环境变量,如果存在,不论是否有效,都不再检查系统设置。

void tzset (void);

extern char *tzname[2];
extern long timezone;
extern int daylight;

tzset 接口用于将 TZ 中的信息解析到全局变量 tzname / timezone / daylight 字段,红色接口通过调用它来设置正确的时区、夏时制等信息,用于后期时间转换。

下面的程序片段演示了各个调用对 tzset 的调用情况:

#include "../apue.h" 
#include <sys/time.h> 
#include <time.h> 

struct timezone
{
    int     tz_minuteswest; /* of Greenwich */
    int     tz_dsttime;     /* type of dst correction to apply */
}; 

void print_tm (struct tm* t)
{
  printf ("%04d-%02d-%02d %02d:%02d:%02d (week day %d) (year day %d) (daylight saving time %d)\n", 
    t->tm_year + 1900, 
    t->tm_mon + 1, 
    t->tm_mday, 
    t->tm_hour, 
    t->tm_min, 
    t->tm_sec, 
    t->tm_wday == 0 ? 7 : t->tm_wday, 
    t->tm_yday + 1, 
    t->tm_isdst); 
}

void print_tz ()
{
  printf ("tzname[0] = %s, tzname[1] = %s, timezone = %d, daylight = %d\n", tzname[0], tzname[1], timezone, daylight); 
}

int main (int argc, char *argv[])
{
  int ret = 0; 
  time_t t1, t2; 
  print_tz (); 
  t1 = time (&t2); 
  printf ("t1 = %ld, t2 = %ld\n", t1, t2); 
  print_tz (); 

  struct timeval tv; 
  struct timezone tzp; 
  ret = gettimeofday (&tv, (void*) &tzp); 
  if (ret == -1)
    perror("gettimeofday"); 

  printf ("sizeof (suseconds_t) = %d, sizeof (struct timeval) = %d, ret %d, tv.sec = %ld, tv.usec = %ld\n", 
          sizeof (suseconds_t), sizeof (struct timeval), ret, tv.tv_sec, tv.tv_usec); 
  printf ("minuteswest = %d, dsttime = %d\n", tzp.tz_minuteswest, tzp.tz_dsttime); 
  print_tz (); 

  struct tm *tm1 = gmtime (&t1); 
  print_tm (tm1); 
  print_tz (); 

  struct tm *tm2 = localtime (&t2); 
  print_tm (tm2); 
  print_tz (); 

  time_t t3 = mktime (tm1); 
  printf ("t3 = %ld\n", t3); 
  print_tz (); 

  printf ("from asctime: %s", asctime (tm1)); 
  print_tz (); 

  printf ("from ctime: %s", ctime (&t1)); 
  print_tz (); 
  return 0; 
}

上面的 demo 演示了在各个时间例程调用后的时区信息 (print_tz),以便观察是否间接调用了 tzset,先来看 Darwin 上运行的结果:

> ./time
tzname[0] =    , tzname[1] =    , timezone = 0, daylight = 0
t1 = 1679811210, t2 = 1679811210
tzname[0] =    , tzname[1] =    , timezone = 0, daylight = 0
sizeof (suseconds_t) = 4, sizeof (struct timeval) = 16, ret 0, tv.sec = 1679811210, tv.usec = 909062
minuteswest = -480, dsttime = 0
tzname[0] = CST, tzname[1] = CDT, timezone = -28800, daylight = 1
2023-03-26 06:13:30 (week day 7) (year day 85) (daylight saving time 0)
tzname[0] = CST, tzname[1] = CDT, timezone = -28800, daylight = 1
2023-03-26 14:13:30 (week day 7) (year day 85) (daylight saving time 0)
tzname[0] = CST, tzname[1] = CDT, timezone = -28800, daylight = 1
t3 = 1679782410
tzname[0] = CST, tzname[1] = CDT, timezone = -28800, daylight = 1
from asctime: Sun Mar 26 06:13:30 2023
tzname[0] = CST, tzname[1] = CDT, timezone = -28800, daylight = 1
from ctime: Sun Mar 26 14:13:30 2023
tzname[0] = CST, tzname[1] = CDT, timezone = -28800, daylight = 1

从输出中可以看到:

  • 时区默认为空,timezone 时间偏移为 0,daylight 为 false
  • 调用 time 对时区没有影响
  • 调用 gettimeofday 并传递时区信息,在 Darwin 上有时区信息返回并更改了它们:时区 CST:CDT, timezone 为 +8 小时,daylight 为 true
  • 调用 gmtime/localtime/mktime/asctime/ctime 后时区信息不变

由于后五个输出一致,为防止相互干扰,可以通过调整调用顺序,或手动屏蔽其它调用来观察输出,结论是一致的。

需要注意的一点是,mktime 和 asctime 的结果是正确的,这是因为它们使用了 gmtime 的返回值,将其作为本地时间处理了,这直接导致 t3 比 t1 小了 28800 秒。

> ./time
tzname[0] = GMT, tzname[1] = GMT, timezone = 0, daylight = 0
t1 = 1679810748, t2 = 1679810748
tzname[0] = GMT, tzname[1] = GMT, timezone = 0, daylight = 0
sizeof (suseconds_t) = 8, sizeof (struct timeval) = 16, ret 0, tv.sec = 1679810748, tv.usec = 451237
minuteswest = 0, dsttime = 0
tzname[0] = GMT, tzname[1] = GMT, timezone = 0, daylight = 0
2023-03-26 06:05:48 (week day 7) (year day 85) (daylight saving time 0)
tzname[0] = CST, tzname[1] = CDT, timezone = -28800, daylight = 1
2023-03-26 14:05:48 (week day 7) (year day 85) (daylight saving time 0)
tzname[0] = CST, tzname[1] = CST, timezone = -28800, daylight = 0
t3 = 1679810748
tzname[0] = CST, tzname[1] = CST, timezone = -28800, daylight = 0
from asctime: Sun Mar 26 14:05:48 2023
tzname[0] = CST, tzname[1] = CST, timezone = -28800, daylight = 0
from ctime: Sun Mar 26 14:05:48 2023
tzname[0] = CST, tzname[1] = CST, timezone = -28800, daylight = 0

上面是在 linux 系统上运行的结果,和 Darwin 有以下不同:

  • 时区默认为 GMT:GMT
  • 调用 gettimeofday 并传递时区信息的情况下没有返回信息,也没有更改它们,也就是说和调用 time 效果一致
  • 调用 gmtime 后时区变为 CST:CDT,timezone 为 +8 小时,daylight 为 true,这和书上讲的有出入,理应不变才对。不过并没有影响接口返回的结果
  • 调用 localtime/mktime/asctime/ctime 后时区变为 CST:CST,timezone +8 小时,daylight 为 false, 但是 mktime 和 asctime 结果不正确

其中 mktime 在使用 gmtime 的结果作为输入后,居然得到了和 time 一样的结果,实在是匪夷所思,导致后面的 asctime 结果也跟着出错。转念一想,是否是因为 gmtime 和 localtime 返回了同一块静态存储区呢?加入下面的一行代码印证:

  printf ("gmt %p, local %p\n", tm1, tm2); 

新日志显示果然如此:

gmt 0x7f2206a8cda0, local 0x7f2206a8cda0

而在 Darwin 上则不同:

gmt 0x7facbce04330, local 0x7facbcc058d0

看来 Darwin 确实做的要稍好一些,不同接口返回了不同静态缓冲区。不过对于这种静态对象,能不缓存还是不要缓存了,免的同类型的相互覆盖,下面是 linux 改进后的输出:

> ./time
tzname[0] = GMT, tzname[1] = GMT, timezone = 0, daylight = 0
t1 = 1679814314, t2 = 1679814314
tzname[0] = GMT, tzname[1] = GMT, timezone = 0, daylight = 0
sizeof (suseconds_t) = 8, sizeof (struct timeval) = 16, ret 0, tv.sec = 1679814314, tv.usec = 70725
minuteswest = 0, dsttime = 0
tzname[0] = GMT, tzname[1] = GMT, timezone = 0, daylight = 0
2023-03-26 07:05:14 (week day 7) (year day 85) (daylight saving time 0)
tzname[0] = CST, tzname[1] = CDT, timezone = -28800, daylight = 1
2023-03-26 15:05:14 (week day 7) (year day 85) (daylight saving time 0)
tzname[0] = CST, tzname[1] = CST, timezone = -28800, daylight = 0
t3 = 1679785514
tzname[0] = CST, tzname[1] = CST, timezone = -28800, daylight = 0
from asctime: Sun Mar 26 07:05:14 2023
tzname[0] = CST, tzname[1] = CST, timezone = -28800, daylight = 0
from ctime: Sun Mar 26 15:05:14 2023
tzname[0] = CST, tzname[1] = CST, timezone = -28800, daylight = 0

mktime 终于正常了。至于 linux gmtime 是否调用了 tzset 的问题,留待以后浏览 glibc 源码再行确认。

系统时区设置

在没有定义 TZ 环境变量时,会查找当前的系统时区设置。系统时区表示方式随系统不同而不同:

  • CentOS 是文件 /etc/localtime 链接到 /usr/share/zoneinfo 目录下的一个时区文件
  • Ubuntu 则是在 /etc/timezone 文件中直接记录了时区信息
  • Darwin 和 CentOS 类似,只是链接目标不同,到了 /var/db/timezone/zoneinfo 目录下面

时区一般在安装系统时进行设置,也可以在系统设置面板中更改。在某些没有 GUI 的场景中 (远程 ssh),也可以通过 tzselect 来更改时区:

查看代码
 > tzselect
Please identify a location so that time zone rules can be set correctly.
Please select a continent or ocean.
 1) Africa
 2) Americas
 3) Antarctica
 4) Arctic Ocean
 5) Asia
 6) Atlantic Ocean
 7) Australia
 8) Europe
 9) Indian Ocean
10) Pacific Ocean
11) none - I want to specify the time zone using the Posix TZ format.
#? 5
Please select a country.
 1) Afghanistan		  18) Israel		    35) Palestine
 2) Armenia		  19) Japan		    36) Philippines
 3) Azerbaijan		  20) Jordan		    37) Qatar
 4) Bahrain		  21) Kazakhstan	    38) Russia
 5) Bangladesh		  22) Korea (North)	    39) Saudi Arabia
 6) Bhutan		  23) Korea (South)	    40) Singapore
 7) Brunei		  24) Kuwait		    41) Sri Lanka
 8) Cambodia		  25) Kyrgyzstan	    42) Syria
 9) China		  26) Laos		    43) Taiwan
10) Cyprus		  27) Lebanon		    44) Tajikistan
11) East Timor		  28) Macau		    45) Thailand
12) Georgia		  29) Malaysia		    46) Turkmenistan
13) Hong Kong		  30) Mongolia		    47) United Arab Emirates
14) India		  31) Myanmar (Burma)	    48) Uzbekistan
15) Indonesia		  32) Nepal		    49) Vietnam
16) Iran		  33) Oman		    50) Yemen
17) Iraq		  34) Pakistan
#? 9
Please select one of the following time zone regions.
1) Beijing Time
2) Xinjiang Time
#? 1

The following information has been given:

	China
	Beijing Time

Therefore TZ='Asia/Shanghai' will be used.
Local time is now:	Sun Mar 12 17:37:12 CST 2023.
Universal Time is now:	Sun Mar 12 09:37:12 UTC 2023.
Is the above information OK?
1) Yes
2) No
#? 1

You can make this change permanent for yourself by appending the line
	TZ='Asia/Shanghai'; export TZ
to the file '.profile' in your home directory; then log out and log in again.

Here is that TZ value again, this time on standard output so that you
can use the /usr/bin/tzselect command in shell scripts:
Asia/Shanghai

根据提示一步步选择就可以了,注意这个命令执行后时区并没有变更,它只是根据用户选择的地区提供了 TZ 环境变量的内容,后续还需要用户手动设置一下,最终还是走的环境变量的方式,毕竟这种方式有优先级,能影响最终的结果。如果不想设置环境变量,也直接更改系统文件内容 (Ubuntu) 或软链接指向 (CentOS/Darwin),这种需要提权,必需有管理员权限才可以。

CentOS 和 Darwin 上的时区文件为二进制,可以通过 zdump 查看:

查看代码
 > zdump -v /usr/share/zoneinfo/Asia/Shanghai 
/usr/share/zoneinfo/Asia/Shanghai  -9223372036854775808 = NULL
/usr/share/zoneinfo/Asia/Shanghai  -9223372036854689408 = NULL
/usr/share/zoneinfo/Asia/Shanghai  Mon Dec 31 15:54:16 1900 UTC = Mon Dec 31 23:59:59 1900 LMT isdst=0 gmtoff=29143
/usr/share/zoneinfo/Asia/Shanghai  Mon Dec 31 15:54:17 1900 UTC = Mon Dec 31 23:54:17 1900 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Sat Apr 12 15:59:59 1919 UTC = Sat Apr 12 23:59:59 1919 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Sat Apr 12 16:00:00 1919 UTC = Sun Apr 13 01:00:00 1919 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Tue Sep 30 14:59:59 1919 UTC = Tue Sep 30 23:59:59 1919 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Tue Sep 30 15:00:00 1919 UTC = Tue Sep 30 23:00:00 1919 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Fri May 31 15:59:59 1940 UTC = Fri May 31 23:59:59 1940 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Fri May 31 16:00:00 1940 UTC = Sat Jun  1 01:00:00 1940 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Sat Oct 12 14:59:59 1940 UTC = Sat Oct 12 23:59:59 1940 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Sat Oct 12 15:00:00 1940 UTC = Sat Oct 12 23:00:00 1940 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Fri Mar 14 15:59:59 1941 UTC = Fri Mar 14 23:59:59 1941 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Fri Mar 14 16:00:00 1941 UTC = Sat Mar 15 01:00:00 1941 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Sat Nov  1 14:59:59 1941 UTC = Sat Nov  1 23:59:59 1941 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Sat Nov  1 15:00:00 1941 UTC = Sat Nov  1 23:00:00 1941 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Fri Jan 30 15:59:59 1942 UTC = Fri Jan 30 23:59:59 1942 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Fri Jan 30 16:00:00 1942 UTC = Sat Jan 31 01:00:00 1942 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Sat Sep  1 14:59:59 1945 UTC = Sat Sep  1 23:59:59 1945 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Sat Sep  1 15:00:00 1945 UTC = Sat Sep  1 23:00:00 1945 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Tue May 14 15:59:59 1946 UTC = Tue May 14 23:59:59 1946 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Tue May 14 16:00:00 1946 UTC = Wed May 15 01:00:00 1946 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Mon Sep 30 14:59:59 1946 UTC = Mon Sep 30 23:59:59 1946 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Mon Sep 30 15:00:00 1946 UTC = Mon Sep 30 23:00:00 1946 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Mon Apr 14 15:59:59 1947 UTC = Mon Apr 14 23:59:59 1947 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Mon Apr 14 16:00:00 1947 UTC = Tue Apr 15 01:00:00 1947 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Fri Oct 31 14:59:59 1947 UTC = Fri Oct 31 23:59:59 1947 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Fri Oct 31 15:00:00 1947 UTC = Fri Oct 31 23:00:00 1947 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Fri Apr 30 15:59:59 1948 UTC = Fri Apr 30 23:59:59 1948 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Fri Apr 30 16:00:00 1948 UTC = Sat May  1 01:00:00 1948 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Thu Sep 30 14:59:59 1948 UTC = Thu Sep 30 23:59:59 1948 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Thu Sep 30 15:00:00 1948 UTC = Thu Sep 30 23:00:00 1948 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Sat Apr 30 15:59:59 1949 UTC = Sat Apr 30 23:59:59 1949 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Sat Apr 30 16:00:00 1949 UTC = Sun May  1 01:00:00 1949 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Fri May 27 14:59:59 1949 UTC = Fri May 27 23:59:59 1949 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Fri May 27 15:00:00 1949 UTC = Fri May 27 23:00:00 1949 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Sat May  3 17:59:59 1986 UTC = Sun May  4 01:59:59 1986 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Sat May  3 18:00:00 1986 UTC = Sun May  4 03:00:00 1986 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Sat Sep 13 16:59:59 1986 UTC = Sun Sep 14 01:59:59 1986 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Sat Sep 13 17:00:00 1986 UTC = Sun Sep 14 01:00:00 1986 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Sat Apr 11 17:59:59 1987 UTC = Sun Apr 12 01:59:59 1987 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Sat Apr 11 18:00:00 1987 UTC = Sun Apr 12 03:00:00 1987 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Sat Sep 12 16:59:59 1987 UTC = Sun Sep 13 01:59:59 1987 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Sat Sep 12 17:00:00 1987 UTC = Sun Sep 13 01:00:00 1987 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Sat Apr 16 17:59:59 1988 UTC = Sun Apr 17 01:59:59 1988 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Sat Apr 16 18:00:00 1988 UTC = Sun Apr 17 03:00:00 1988 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Sat Sep 10 16:59:59 1988 UTC = Sun Sep 11 01:59:59 1988 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Sat Sep 10 17:00:00 1988 UTC = Sun Sep 11 01:00:00 1988 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Sat Apr 15 17:59:59 1989 UTC = Sun Apr 16 01:59:59 1989 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Sat Apr 15 18:00:00 1989 UTC = Sun Apr 16 03:00:00 1989 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Sat Sep 16 16:59:59 1989 UTC = Sun Sep 17 01:59:59 1989 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Sat Sep 16 17:00:00 1989 UTC = Sun Sep 17 01:00:00 1989 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Sat Apr 14 17:59:59 1990 UTC = Sun Apr 15 01:59:59 1990 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Sat Apr 14 18:00:00 1990 UTC = Sun Apr 15 03:00:00 1990 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Sat Sep 15 16:59:59 1990 UTC = Sun Sep 16 01:59:59 1990 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Sat Sep 15 17:00:00 1990 UTC = Sun Sep 16 01:00:00 1990 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Sat Apr 13 17:59:59 1991 UTC = Sun Apr 14 01:59:59 1991 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  Sat Apr 13 18:00:00 1991 UTC = Sun Apr 14 03:00:00 1991 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Sat Sep 14 16:59:59 1991 UTC = Sun Sep 15 01:59:59 1991 CDT isdst=1 gmtoff=32400
/usr/share/zoneinfo/Asia/Shanghai  Sat Sep 14 17:00:00 1991 UTC = Sun Sep 15 01:00:00 1991 CST isdst=0 gmtoff=28800
/usr/share/zoneinfo/Asia/Shanghai  9223372036854689407 = NULL
/usr/share/zoneinfo/Asia/Shanghai  9223372036854775807 = NULL

也可以指定相对路径或国家缩写,如
zdump -v Asia/Shanghai

zdump -v PRC
,输出信息一致。看起来文件内容中包含了该时区对应的夏时制起始时间,怪不得文件尺寸各不相等。这或许就是 Darwin 中 gettimeofday 返回当前时间是否处于夏时制的依据,关于夏时制,请参考下节。

不加 -v 选项调用 zdump,会返回时区的当前时间:

> zdump America/New_York 
America/New_York  Sun Mar 26 03:31:01 2023 EDT
> zdump PRC
PRC  Sun Mar 26 15:31:05 2023 CST
> date
Sun Mar 26 15:31:10 CST 2023

可以看到和 date 命令的输出有些许差别。如果时区不合法或没找到,通通返回 GMT 时间。

夏时制

夏时制也称夏令时 (Daylight Saving Time),直译过来就是日光节约时间制。这是因为北半球夏季时白天变长、夜晚变短,有人认为通过推行夏时制可以有效利用天光,节约晚上能源消耗。

具体操作就是,在进入夏季某天后,统一将时钟调快一小时,此时早上七点将变为早上八点,提早开始上班上学,晚上五点将变为晚上六点,提早开始下班放学。即通过让人早起早睡来达到多利用天光的目的,而且统一调整时间制后,学校、公司都不用调整了,省去了很多不一致的地方。到某个夏季结束的一天,再统一将时钟调慢一小时,人们又可以晚起晚睡了,自此时间恢复到往常一样。

我国曾实行过六年的夏时制 (1986-1991),发现对社会节约用电效果有限,另外还有其它弊端,例如切换夏时制后睡眠不足导致的车祸、列车时刻表的调整、全国一个时区带来的偏远地区时差更大等等问题,最终放弃了这一做法。欧盟也在 2021 年投票废弃了夏时制,目前在执行夏时制的比较大的国家就剩美国、加拿大、澳大利亚等。

再来复习一下文章开关的关系图:

其中虚线部分表示受夏时制影响,POSIX 时间例程中的 time、gettimeofday 不考虑夏时制,否则 Epoch 凭空多了 3600 或少了 3600 是什么鬼。下面举个例子:

> date 
Sun Mar 26 16:00:40 CST 2023
> export TZ=America/New_York
> date
Sun Mar 26 04:00:51 EDT 2023
> zdump America/New_York
America/New_York  Sun Mar 26 04:00:55 2023 EDT

已知美国纽约在 2023-03-12 已进入夏时制,持续直到 11-05:

> zdump -v America/New_York | grep 2023 
...
America/New_York  Sun Mar 12 06:59:59 2023 UTC = Sun Mar 12 01:59:59 2023 EST isdst=0
America/New_York  Sun Mar 12 07:00:00 2023 UTC = Sun Mar 12 03:00:00 2023 EDT isdst=1
America/New_York  Sun Nov  5 05:59:59 2023 UTC = Sun Nov  5 01:59:59 2023 EDT isdst=1
America/New_York  Sun Nov  5 06:00:00 2023 UTC = Sun Nov  5 01:00:00 2023 EST isdst=0
...

那日期 2023-03-26 应该处于夏时制期间,时间理应调慢一小时,即中国东 8 区与美国西 4 区之差再加一小时——13 小时时差才对,而实际仍只有 12 小时时差 (16:00 vs 4:00)。上面的 demo 在 linux 和 Darwin 上运行结果一致。

下面再来考虑一下其它日期例程是否夏时制敏感,为了说明问题,保留上例中
export TZ=America/New_York
设置,注意运行这个例子和当前系统时间也有关系 (必需是在所在区域的夏时制范围内):

> ./time
tzname[0] = GMT, tzname[1] = GMT, timezone = 0, daylight = 0
t1 = 1679819691, t2 = 1679819691
tzname[0] = GMT, tzname[1] = GMT, timezone = 0, daylight = 0
sizeof (suseconds_t) = 8, sizeof (struct timeval) = 16, ret 0, tv.sec = 1679819691, tv.usec = 695922
minuteswest = 0, dsttime = 0
tzname[0] = GMT, tzname[1] = GMT, timezone = 0, daylight = 0
2023-03-26 08:34:51 (week day 7) (year day 85) (daylight saving time 0)
tzname[0] = EST, tzname[1] = EDT, timezone = 18000, daylight = 1
2023-03-26 04:34:51 (week day 7) (year day 85) (daylight saving time 1)
tzname[0] = EST, tzname[1] = EDT, timezone = 18000, daylight = 1
t3 = 1679837691
tzname[0] = EST, tzname[1] = EDT, timezone = 18000, daylight = 1
from asctime: Sun Mar 26 09:34:51 2023
tzname[0] = EST, tzname[1] = EDT, timezone = 18000, daylight = 1
from ctime: Sun Mar 26 04:34:51 2023
tzname[0] = EST, tzname[1] = EDT, timezone = 18000, daylight = 1

发现几点有趣的变化:

  • 根据 TZ 环境变量的设置解析出了美国纽约所在的西 4 区 (timezone = 18000)
  • gmttime 和 localtime 返回的 tm_isdst 不同
  • asctime 的输出表明它在 gmtime 的返回结果之上加了 1 个小时,看起来是受夏时制影响了

好好分析一下第三条现象,asctime 打印的 tm1 结构体是 gmtime 返回的,不应该受夏时制影响才对,那将 asctime 和 ctime 的输入参数替换为 localtime 返回的 tm2 和 t2 会如何呢?

from asctime: Sun Mar 26 05:03:16 2023
tzname[0] = EST, tzname[1] = EDT, timezone = 18000, daylight = 1
from ctime: Sun Mar 26 05:03:16 2023
tzname[0] = EST, tzname[1] = EDT, timezone = 18000, daylight = 1

结论是完全没影响。那上面突然增加的 1 小时怎么解释呢?难不成是 mktime 修改了 tm 结构体?增加下面的代码用于验证:

  time_t t3 = mktime (&tm1); 
  printf ("t3 = %ld\n", t3); 
  print_tm (&tm1); 
  print_tz (); 

  printf ("from asctime: %s", asctime (&tm1)); 
  print_tz (); 

  printf ("from ctime: %s", ctime (&t3)); 
  print_tz (); 
  return 0; 

在 mktime 后打印 tm1 的内容,并将 asctime 和 ctime 的参数指向 mktime 的结果,新的日志如下:

> ./time
tzname[0] = GMT, tzname[1] = GMT, timezone = 0, daylight = 0
t1 = 1679821804, t2 = 1679821804
tzname[0] = GMT, tzname[1] = GMT, timezone = 0, daylight = 0
sizeof (suseconds_t) = 8, sizeof (struct timeval) = 16, ret 0, tv.sec = 1679821804, tv.usec = 289229
minuteswest = 0, dsttime = 0
tzname[0] = GMT, tzname[1] = GMT, timezone = 0, daylight = 0
2023-03-26 09:10:04 (week day 7) (year day 85) (daylight saving time 0)
tzname[0] = EST, tzname[1] = EDT, timezone = 18000, daylight = 1
2023-03-26 05:10:04 (week day 7) (year day 85) (daylight saving time 1)
tzname[0] = EST, tzname[1] = EDT, timezone = 18000, daylight = 1
t3 = 1679839804
2023-03-26 10:10:04 (week day 7) (year day 85) (daylight saving time 1)
tzname[0] = EST, tzname[1] = EDT, timezone = 18000, daylight = 1
from asctime: Sun Mar 26 10:10:04 2023
tzname[0] = EST, tzname[1] = EDT, timezone = 18000, daylight = 1
from ctime: Sun Mar 26 10:10:04 2023
tzname[0] = EST, tzname[1] = EDT, timezone = 18000, daylight = 1

果然是 mktime 做的手脚!将 tm1 结构体中的 tm_hour 增加了 1 小时,看起来是受 tm_isdst 影响了。然而 tm1  的 tm_isdst 值为 0,不应该影响 mktime 的结果,神奇,怀疑是有 tzset 在内部被调用了。下面是另外一些尝试:

  • 将 tm1 的内容复制一份传递给 mktime,那么 asctime 的结果将不再增加 1 小时,可见 asctime 是对夏时制不敏感的
  • ctime 是比较神奇的,它会根据不同的 time_t 做出不同的反应:
    • 解释 mktime 返回的 t3 时它增加了 1 个小时,为了排除 mktime 的影响,直接将 t3 设置为 t1 + 18000,仍然能加 1 小时
    • 解释 time 和 gettimeofday 返回的 t1/t2 时它却不增加时间

对于 ctime 的神奇表现简直是匪夷所思,一个小小的 time_t 中无法包含任何关于夏时制的信息;如果通过全局变量,那么将 mktime 都注释掉了仍能增加 1 小时,这让人上哪讲理去。

同样的现象出在 mktime 身上,如果传递的是 localtime 的结果,则 mktime 不会增加 1 小时,后续的 ctime 也不会增加,可见他们的问题是一致的——传递 gmtime 的结果到 mktime 可能会有意想不到的结果,最好不要这样做。

最终结论是,当正常使用时间例程时,它们都不受夏时制影响;如果错误的将 gmtime 结果传递给 mktime,则
mktime 和 ctime 会受夏时制影响自动增加 1 小时。
后者受影响的规律还没有摸清楚,留待后面浏览 mktime / ctime 源码时给出解释。

以上现象在 Darwin 上也能复现。最后上一张 linux 上 strace 的输出:

> strace ./time |& less
...
brk(NULL)                               = 0x257d000
brk(0x259e000)                          = 0x259e000
brk(NULL)                               = 0x259e000
open("/usr/share/zoneinfo/America/New_York", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=3535, ...}) = 0
fstat(3, {st_mode=S_IFREG|0644, st_size=3535, ...}) = 0
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fa3b07b7000
read(3, "TZif2\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\5\0\0\0\5\0\0\0\0"..., 4096) = 3535
lseek(3, -2260, SEEK_CUR)               = 1275
read(3, "TZif2\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\6\0\0\0\6\0\0\0\0"..., 4096) = 2260
close(3)                                = 0
munmap(0x7fa3b07b7000, 4096)            = 0
write(1, "tzname[0] = GMT, tzname[1] = GMT"..., 979tzname[0] = GMT, tzname[1] = GMT, timezone = 0, daylight = 0
t1 = 1679823212, t2 = 1679823212
tzname[0] = GMT, tzname[1] = GMT, timezone = 0, daylight = 0
sizeof (suseconds_t) = 8, sizeof (struct timeval) = 16, ret 0, tv.sec = 1679823212, tv.usec = 189083
minuteswest = 0, dsttime = 0
...

可以看到在设置了 TZ 环境变量的情况下,时区文件仍被打开以确认夏时制的起始结束范围。

可读性

time_t 表示的 Epoch 适合计算机存储、计算,但对人并不友好。将它们转换为人能理解的日期时间需要借助于以下例程:

char *asctime(const struct tm *tm);
char *ctime(const time_t *timep);
size_t strftime(char *s, size_t max, const char *format, const struct tm *tm);

其中 asctime 和 ctime 前面已经介绍过,它们分别将 strut tm 和 time_t 转换为固定的时间格式。strftime 用于将 strut tm 转换为任意用户指定的格式,类似于 printf 做的工作。

其中 s 和 max 参数指定了输出缓存区,如果生成的字符串长度 (包含结尾 null) 大于 max,则返回 0;否则返回生成的字符串长度 (不包含结尾 null)。

让我们再回顾一下开头的关系图:

strftime 和 strptme 互逆,asctime 生成的 string 也可以通过 strptime 转换回 struct tm,但没有直接从 string 转换到 time_t 的途径,也没有直接从 time_t 生成格式化字符串的路径。

下面是对 format 参数的说明:

格式 说明 实例
%Y 2023
%C 年/100 20
%y 年%100: [00-99] 23
%G ISO 基于周的年 2023
%g ISO 基于周的年%100 23
%m 月: [01-12] 04
%b / %h 月名缩写 Apr
%B 月名全写 April
%w 日in周: [0-6],周日: 0 6
%u ISO 日in周: [1-7],周日: 7 7
%a 日in周缩写 Sun
%A 日in周全写 Sunday
%d 日in月: [01-31],前导零 02
%e 日in月: [1-31],前导空格 2
%j 日in年: [001-366] 092
%U 星期日周数: [00-53] 14
%W 星期一周数: [01-53] 13
%V ISO 周数: [01-53] 13
%D / %x %m/%d/%y 04/02/23
%F %Y-%m-%d 2023-04-02
%H 24 时制小时: [00-23] 17
%I 12 时制小时: [01-12] 05
%M 分: [00-59] 38
%S 秒: [00-60] 31
%T / %X 24 时制时间: %H:%M:%S 17:38:31
%R %H:%M 17:38
%p AM/PM PM
%r 12 时制时间: %I:%M:%S %p 05:38:31 PM
%c 日期和时间 Sun Apr  2 17:38:31 2023
%z ISO UTC 偏移量 +0800
%Z 时区名 CST
%n 换行符
%t 水平制表符
%% 百分号 %

大部分选项是直接明了的,有几个需要单独解释下:

  • %g/%G: 当前周所在的年,这里一周是从周一到周日,例如 2023-01-01 (周日) 对应的年却是 2022
  • %U:日期在该年中所属的周数,包含该年中第一个星期日的周是第一周 (即星期日周数),例如 2023-01-01 (周日) 对应的周是 1
  • %W:同上,不同点在于包含该年中第一个星期一的周数是第一周 (即星期一周数),例如 2023-01-01 (周日) 对应的周是 0
  • %V:同上,不同点在于确定第一周的算法更复杂了:若某周包含了 1 月 1 日,而且至少包含了其后的另外 3 天,那么该周才被视为这年的第一周;否则该周为上一年的最后一周。还是以 2023-01-01 (周日) 为例,它对应的周是 52,即上一年最后一周

下面的代码可以用来测试任何格式化选项:

#include "../apue.h" 
#include <sys/time.h> 
#include <time.h> 

void print_tm (struct tm* t)
{
  printf ("%04d-%02d-%02d %02d:%02d:%02d (week day %d) (year day %d) (daylight saving time %d)\n", 
    t->tm_year + 1900, 
    t->tm_mon + 1, 
    t->tm_mday, 
    t->tm_hour, 
    t->tm_min, 
    t->tm_sec, 
    t->tm_wday, 
    t->tm_yday + 1, 
    t->tm_isdst); 
}

void my_strftime (char const* fmt, struct tm* t)
{
  char buf[64] = { 0 }; 
  int ret = strftime (buf, sizeof (buf), fmt, t); 
  printf ("[%02d] '%s': %s\n", ret, fmt, buf); 
}

int main (int argc, char *argv[])
{
  int ret = 0; 
  time_t now = time (NULL); 
  printf ("now = %ld\n", now); 

  struct tm *t = localtime (&now); 
  print_tm (t); 
  printf ("year group:\n");
  my_strftime ("%Y", t); 
  my_strftime ("%C", t); 
  my_strftime ("%y", t); 
  my_strftime ("%G", t); 
  my_strftime ("%g", t); 

  printf ("month group:\n"); 
  my_strftime ("%m", t); 
  my_strftime ("%b", t); 
  my_strftime ("%h", t); 
  my_strftime ("%B", t); 

  printf ("day group:\n");
  my_strftime ("%w", t); 
  my_strftime ("%u", t); 
  my_strftime ("%a", t); 
  my_strftime ("%A", t); 
  my_strftime ("%d", t); 
  my_strftime ("%e", t); 
  my_strftime ("%j", t); 

  printf ("week group:\n"); 
  my_strftime ("%U", t); 
  my_strftime ("%W", t); 
  my_strftime ("%V", t); 

  printf ("date group\n"); 
  my_strftime ("%D", t); 
  my_strftime ("%x", t); 
  my_strftime ("%F", t); 

  printf ("time group\n"); 
  my_strftime ("%H", t); 
  my_strftime ("%k", t); 
  my_strftime ("%I", t); 
  my_strftime ("%l", t); 
  my_strftime ("%M", t); 
  my_strftime ("%S", t); 
  my_strftime ("%T", t); 
  my_strftime ("%X", t); 
  my_strftime ("%R", t); 
  my_strftime ("%p", t); 
  my_strftime ("%P", t); 
  my_strftime ("%r", t); 
  my_strftime ("%c", t); 
  my_strftime ("%s", t); 

  printf ("timezone group\n"); 
  my_strftime ("%z", t); 
  my_strftime ("%Z", t); 

  printf ("common group\n"); 
  my_strftime ("%n", t); 
  my_strftime ("%t", t); 
  my_strftime ("%%", t); 
  return 0; 
}

下面是代码的典型输出:

> ./timeprintf 
now = 1680431880
2023-04-02 18:38:00 (week day 0) (year day 92) (daylight saving time 0)
year group:
[04] '%Y': 2023
[02] '%C': 20
[02] '%y': 23
[04] '%G': 2023
[02] '%g': 23
month group:
[02] '%m': 04
[03] '%b': Apr
[03] '%h': Apr
[05] '%B': April
day group:
[01] '%w': 0
[01] '%u': 7
[03] '%a': Sun
[06] '%A': Sunday
[02] '%d': 02
[02] '%e':  2
[03] '%j': 092
week group:
[02] '%U': 14
[02] '%W': 13
[02] '%V': 13
date group
[08] '%D': 04/02/23
[08] '%x': 04/02/23
[10] '%F': 2023-04-02
time group
[02] '%H': 18
[02] '%k': 18
[02] '%I': 06
[02] '%l':  6
[02] '%M': 38
[02] '%S': 00
[08] '%T': 18:38:00
[08] '%X': 18:38:00
[05] '%R': 18:38
[02] '%p': PM
[02] '%P': pm
[11] '%r': 06:38:00 PM
[24] '%c': Sun Apr  2 18:38:00 2023
[10] '%s': 1680431880
timezone group
[05] '%z': +0800
[03] '%Z': CST
common group
[01] '%n': 

[01] '%t': 	
[01] '%%': %

示例中演示了另外一些非标准扩展,例如 %s 展示时间对应的 Epoch 值,在 linux 和 darwin 上都是被支持的。

回顾上面的关系图,strftime 是受时区和夏时制影响的 (标红部分),下面通过导出 TZ 环境变量来验证:

> date
Wed Apr  5 16:28:12 CST 2023
> export TZ=America/New_York
> date
Wed Apr  5 03:28:17 EDT 2023
> ./timeprintf 
now = 1680679740
2023-04-05 03:29:00 (week day 3) (year day 95) (daylight saving time 1)
year group:
[04] '%Y': 2023
[02] '%C': 20
[02] '%y': 23
[04] '%G': 2023
[02] '%g': 23
month group:
[02] '%m': 04
[03] '%b': Apr
[03] '%h': Apr
[05] '%B': April
day group:
[01] '%w': 3
[01] '%u': 3
[03] '%a': Wed
[09] '%A': Wednesday
[02] '%d': 05
[02] '%e':  5
[03] '%j': 095
week group:
[02] '%U': 14
[02] '%W': 14
[02] '%V': 14
date group
[08] '%D': 04/05/23
[08] '%x': 04/05/23
[10] '%F': 2023-04-05
time group
[02] '%H': 03
[02] '%k':  3
[02] '%I': 03
[02] '%l':  3
[02] '%M': 29
[02] '%S': 00
[08] '%T': 03:29:00
[08] '%X': 03:29:00
[05] '%R': 03:29
[02] '%p': AM
[02] '%P': am
[11] '%r': 03:29:00 AM
[24] '%c': Wed Apr  5 03:29:00 2023
[10] '%s': 1680679740
timezone group
[05] '%z': -0400
[03] '%Z': EDT
common group
[01] '%n': 

[01] '%t': 	
[01] '%%': %

添加纽约时区后,strftime 生成的时间与北京时间差了 13 个小时,除去时区跨度 12 个小时 (+8 & -4),还有 1 小时是夏时制引发的。通过 %z 和 %Z 的输出可以观察到时区的变量。对于夏时制,strftime 没有提供对应的 format 参数,所以不好直接确认,只能通过时间差值来间接确认。

char *strptime(const char *s, const char *format, struct tm *tm);

strptime 是 strftime 的逆操作,借助 format 参数解析输入字符串 s,并将结果保存在参数 tm 中,它的返回值有如下几种场景:

  • 解析了部分 format 或一个也没有解析出来,返回 NULL
  • 解析了全部 format,将最后解析位置返回给调用者 (如果恰好为末尾 null,则表示完全匹配)

它的 format 参数和 strftime 几乎完全一致,除以下几点:

  • %t / %n:匹配任意空白
  • %y:69-99 将匹配到 19xx 年,00-68 将匹配到 20xx 年
  • 可添加 E / 0 前缀指定使用当前 locale 使用的日期时间符号

仍以上面的代码为例,如果想查看任意时间的 format 参数效果,可以增加时间参数并通过 strptime 做解析:

int main (int argc, char *argv[])
{
  int ret = 0; 
  struct tm *t = NULL; 
  if (argc == 1) 
  {
    time_t now = time (NULL); 
    printf ("now = %ld\n", now); 
    t = localtime (&now); 
  }
  else if (argc == 2)
  {
    static struct tm tmp = { 0}; 
    char const* ptr = strptime (argv[1], "%F %T", &tmp); 
    if (ptr == NULL)
    {
        printf ("parse time %s failed\n", argv[1]); 
        return -1;
    }

    if (*ptr != NULL)
    {
        printf ("strptime ret:[%d] %s\n", ptr-argv[1], ptr); 
    }

    t = &tmp; 
  }
  else
  {
      printf ("Usage: ./timeprintf [YYYY-MM-DD HH:MM:SS]\n"); 
      exit (1); 
  }
...
}

和之前的区别在于,当用户给定一个额外参数时,尝试使用 strptime 进行解析,如果成功,将解析结果用于后续的 strftime 时间参数。默认按 YYYY-MM-DD HH:MM:SS 格式解析:

> ./timeprintf "2023-01-01 10:00:00"
2023-01-01 10:00:00 (week day 0) (year day 1) (daylight saving time 0)
year group:
[04] '%Y': 2023
[02] '%C': 20
[02] '%y': 23
[04] '%G': 2022
[02] '%g': 22
...

注意需要将整个日期时间参数用引号括起来,不然会被 shell 解析为两个参数。

这里使用今年第一天来验证 %g / %G 的输出,可以看到,因为这天仍属于 2022 的最后一周,所以它们都返回了 2022。

有的人或许有疑问,经 strptime 解析的时间和 localtime 返回的完全一致吗?下面做个试验:

> ./timeprintf > out.1
> ./timeprintf "2023-04-05 16:31:00" > out.2
> diff out.1 out.2
1d0
< now = 1680683460
46c45
< [05] '%z': +0800
---
> [05] '%z': +0000

可以看到,除时区偏移没解析成功外,其它字段确实相符 (没带参数的 timeprintf 使用的时间也是 16:31;00),这也比较好理解,毕竟提供给 strptime 的字符串没带时区信息,如果修改 format 信息带上时区呢?

    char const* ptr = strptime (argv[1], "%F %T %Z", &tmp); 

下面就来试一试:

> ./timeprintf "2023-04-05 16:31:00 CST" > out.2
> diff out.1 out.2
1c1
< now = 1680683460
---
> strptime ret:[20] CST
46c46
< [05] '%z': +0800
---
> [05] '%z': +0000

看起来是没什么改善,特别是额外增加的时区名称 (%Z) 没有被解析。换 %z 试试:

> ./timeprintf "2023-04-05 16:31:00 +0800" > out.2
> diff out.1 out.2
1d0
< now = 1680683460

这次成功了!再次切换到纽约时间:

> ./timeprintf "2023-04-05 16:31:00 -0400" > out.2
> diff out.1 out.2
1d0
< now = 1680683460
46c45
< [05] '%z': +0800
---
> [05] '%z': -0400

除了 %z 字段受影响,其它都没变,特别是 %s 字段一点影响也没有,不应该啊。改用 TZ 环境变量尝试:

> export TZ=America/New_York
> ./timeprintf "2023-04-05 16:31:00 -0400" > out.2
> diff out.1 out.2
1d0
< now = 1680683460
44c43
< [10] '%s': 1680683460
---
> [10] '%s': 1680730260
46,47c45,46
< [05] '%z': +0800
< [03] '%Z': CST
---
> [05] '%z': -0400
> [03] '%Z': EST

这回正常了,看来 strptime 也受时区影响,那它受不受夏时制影响呢?很简单,将上面 %s 输出的 Epoch 用当前时区查看一下就明了:

> unset TZ
> date --date=@1680730260
Thu Apr  6 05:31:00 CST 2023

两者相差 13 小时 (0405 16:31 -> 0406 05:31),中间有夏时制 1 小时的差值,因此受夏时制影响。

最后补充一下,date 命令使用的 format 与 strftime 也基本相同,除以下几点:

  • %z,可添加 : 前缀
    • %z : +hhmm
    • %:z : +hh:mm
    • %::z : +hh:mm:ss
    • %:::z : 适当的精度 (去掉全零后缀)
  • 前缀修饰
    • - :不增加填充字符
    • _ :使用空格填充
    • 0 : 使用零填充
    • ^ : 使用大写
    • # : 使用相反的大小写

例如:

> date +%z 
+0800
> date +%:z 
+08:00
> date +%::z 
+08:00:00
> date +%:::z 
+08
> date +%-z
+800
> date +%_z
 +800
> date +%0z
+0800

另外 mac date 与 unix date 语法差异较大,可能考之前在"闰秒"一节末尾的说明。

其它

前面介绍的都是时间的获取与展示,如果想要设置时间,需要使用另外的例程:

int settimeofday(const struct timeval *tv, const struct timezone *tz);

参数与 gettimeofday 一致。

另外如果只是想计算时间差,尽量不要使用 time 或 gettimeofday,因为它们都会受到用户对系统时间设置的影响。

int clock_getres(clockid_t clk_id, struct timespec *res);
int clock_gettime(clockid_t clk_id, struct timespec *tp);
int clock_settime(clockid_t clk_id, const struct timespec *tp);

这种场景应当使用 clock_gettime 来获取相对时间,clk_id 通常有以下几种选择:

  • CLOCK_REALTIME:系统范围的实时时钟。所有用户所有程序都使用,这和 gettimeofday 函数获取的系统时间是相同的
  • CLOCK_MONOTONIC:单调递增时间,不能被人为修改,需要注意的是并没有指定起始时间,有些系统取了 Epoch 时间,有些系统 (比如Linux) 取 boot 时间
  • CLOCK_BOOTTIME: 同 CLOCK_MONOTONIC一样,只是当系统被挂起时一样会计时  (Linux系统特有)
  • CLOCK_PROCESS_CPUTIME_ID: 进程使用的 CPU 时间,该进程的所有线程的 CPU 时间都会被统计进来
  • CLOCK_THREAD_CPUTIME_ID: 线程使用的 CPU 时间

CLOCK_BOOTTIME 为 linux 拓展,Darwin 上也有其它拓展,为保持可移植性,一般选取 CLOCK_MONOTONIC。clock_getres 是用来获取对应时钟类型能够提供的时间精确度,res 参数保存其精确度。在设置或休眠的时候,时间值也应该是这个精确度的倍数。clock_settime 是用来设置对应时钟的时间,不过有些时钟是不能被设置的。

clock_gettime 返回的 timespec 与 gettimeofday 返回的 timeval 有所不同:

struct timespec {
    time_t   tv_sec;        /* seconds */
    long     tv_nsec;       /* nanoseconds */
};

第一个字段都是 Epoch,第二个字段为纳秒,比 timeval 的毫秒精度要高不少。

针对 timeval,linux 系统提供了一系列例程来支持时间的计算:

void timeradd(struct timeval *a, struct timeval *b,  struct timeval *res);
void timersub(struct timeval *a, struct timeval *b, struct timeval *res);
void timerclear(struct timeval *tvp);
int timerisset(struct timeval *tvp);
int timercmp(struct timeval *a, struct timeval *b, CMP);

比直接手动处理进位、借位方便了不少,不过它们不属于 POSIX 标准。

最后本文介绍的大多例程需要返回一个内部的静态内存的指针,不是可重入的,既不线程安全,也不信号安全,在"时区->TZ 环境变量"一节的例子中已经体验过了。为此,POSIX 提供了可重入版本的时间例程:

char *asctime_r(const struct tm *tm, char *buf);
char *ctime_r(const time_t *timep, char *buf);
struct tm *gmtime_r(const time_t *timep, struct tm *result);
struct tm *localtime_r(const time_t *timep, struct tm *result);

将使用用户提供的参数 (buf/result) 代替内部的静态内存区,如果给定的参数为 NULL,则回退到不带 _r 后缀的版本;如果不为 NULL,则返回这个参数,以便与非重入版本兼容。

参考

[1].
彻底弄懂GMT、UTC、时区和夏令时

[2].
格林尼治标准时间

[3].
协调世界时

[4].
国家授时中心闰秒公告

[5].
应对linux下的闰秒

[6].
聊聊闰秒以及模拟闰秒

[7].
「闰秒」会对 IT 行业造成多大影响?有什么好的解决方法?

[8].
mac date命令

[9].
linux系统date命令(时间戳与日期相互转换)

[10].
修改系统时区 /etc/localtime

[11].
[Linux] 设置系统时区

[12].
Linux 系统设置 : zdump 命令详解

[13].
那些年,我国也实行过夏时制

[14].
tzset 的作用

[15].
自顶向下地聊聊C++的时间处理和chrono库

[16].
双时制为何在欧洲仍难废除?

[17].
gettimeofday、clockgettime 以及不同时钟源的影响

[18].
Linux时间类型、函数和休眠函数

一、数据库类型

  • 关系数据库管理系统(RDBMS)
  • 非关系数据库管理系统(NoSQL)

按照预先设置的组织机构,将数据存储在物理介质上(即:硬盘上)
数据之间可以做无关联操作 (例如: 多表查询,嵌套查询,外键等)

主流的RDBMS软件:MySQL、MariaDB、Oracle、DB2、SQL Server;要存储的数据是有固定格式的(例如:要向银行存现金,需要录入:姓名,年龄,金额,家庭住址等),并且是永久存储的,类似这种对于同一个业务,录入数据的方式一样的采用关系型数据库。

二、NoSQL(NoSQL = Not Only SQL)

意思是“不仅仅是SQL”
泛指非关系型数据库,不需要预先定义数据存储结构,每条记录可以有不同的 数据类型 和 字段个数

NoSQL主流软件:Memcached、Redis、MongoDB、Neo4j、FlockDB

三、Redis介绍

Remote Dictionary Server(远程字段服务器)是一款高性能的(Key/Values)分布式内存数据库
支持数据持久化(定期把内存里数据存储到硬盘)
支持多种数据类型 string、list、hash
支持 master-slave 模式数据备份

中文网站 www.redis.cn

四、部署Redis服务

环境准备,创建template主机,ip地址为192.168.11.10
PS:官网稳定版6的版本,编译的时候一直报错找不到src目录
换成4版本成功的截图:

步骤一:直接wget下载到/root目录下

[root@template ~]# wget -c http://download.redis.io/releases/redis-4.0.8.tar.gz

步骤二:源码编译安装

# 安装编译环境gcc gcc-c++
[root@template~]# yum -y install gcc

# 解压到指定目录,个人习惯
[root@template ~]# tar xf redis-4.0.8.tar.gz -C /usr/local/

# 进入目录
[root@template ~]# cd /usr/local/redis-4.0.8/
[root@template redis-4.0.8]# ls
00-RELEASENOTES  CONTRIBUTING  deps     Makefile   README.md   runtest          runtest-moduleapi  sentinel.conf  tests   utils
BUGS             COPYING       INSTALL  MANIFESTO  redis.conf  runtest-cluster  runtest-sentinel   src            TLS.md

# 编译安装
[root@template redis-4.0.8]# make && make install

# 测试
[root@template redis-4.0.8]# redis-
redis-benchmark  redis-check-aof  redis-check-rdb  redis-cli        redis-sentinel   redis-server

步骤三:初始配置,配置服务运行参数

[root@template utils]# pwd
/usr/local/redis-4.0.8/utils
[root@template utils]# ./install_server.sh         执行源码目录下的初始化脚本
端口						6379
主配置文件					/etc/redis/6379.conf
日志文件					        /var/log/redis_6379.log
数据库目录					/var/lib/redis/6379
服务启动程序				        /usr/local/bin/redis-server
命令行连接命令				        /usr/local/bin/redis-cli

# 运行初始化脚本,一路回车即可
Welcome to the redis service installer
This script will help you easily set up a running redis server

选择redis端口号:【6379】 回车确认

Please select the redis port for this instance: [6379]
Selecting default: 6379

选择redis主配置文件:【/etc/redis/6379.conf】 回车确认

Please select the redis config file name [/etc/redis/6379.conf]
Selected default - /etc/redis/6379.conf

选择redis日志文件:【/var/log/redis_6379.log】 回车确认

Please select the redis log file name [/var/log/redis_6379.log]
Selected default - /var/log/redis_6379.log

选择redis数据库目录:【/var/lib/redis/6379】 回车确认

Please select the data directory for this instance [/var/lib/redis/6379]
Selected default - /var/lib/redis/6379

选择redis启动程序:【/usr/local/bin/redis-server】 回车确认

Please select the redis executable path [/usr/local/bin/redis-server]

以上选择的配置

Selected config:
Port           : 6379
Config file    : /etc/redis/6379.conf
Log file       : /var/log/redis_6379.log
Data dir       : /var/lib/redis/6379
Executable     : /usr/local/bin/redis-server
Cli Executable : /usr/local/bin/redis-cli

确认,则选择回车即可;否则按 Ctrl + C 重新配置

Is this ok? Then press ENTER to go on or Ctrl-C to abort.
Copied /tmp/6379.conf => /etc/init.d/redis_6379
Installing service...
Successfully added to chkconfig!
Successfully added to runlevels 345!
Starting Redis server...
Installation successful!         #安装成功

步骤四:查看redis的启动端口号

[root@template utils]# ss  -lntup | grep redis
tcp    LISTEN     0      128    127.0.0.1:6379                  *:*                   users:(("redis-server",pid=9043,fd=6))

步骤五:管理redis服务,通过脚本的方式停止redis的服务

[root@template utils]# /etc/init.d/redis_6379 stop
Stopping ...
Redis stopped
[root@template utils]#
[root@template utils]#
[root@template utils]# ss  -lntup | grep redis

步骤六:重新开启redis服务

[root@template utils]# /etc/init.d/redis_6379 start
Starting Redis server...
[root@template utils]# ss  -lntup | grep redis
tcp    LISTEN     0      128    127.0.0.1:6379                  *:*                   users:(("redis-server",pid=10977,fd=6))

步骤七:连接服务:redis-cli 默认连接本机的redis服务

[root@template utils]# redis-cli

# 使用ping命令,查看连接是否成功,结果是PONG,则代表redis正常连接
127.0.0.1:6379> ping
PONG
127.0.0.1:6379>
127.0.0.1:6379> keys *				#使用keys命令,查看当前库下的所有数据
127.0.0.1:6379> set school tarena		#使用set命令,存入数据,school:tarena
127.0.0.1:6379> get school			#使用get命令,从内存中,取出变量"school"对应的值
127.0.0.1:6379> keys *				#使用keys命令,查看当前库下的所有数据
127.0.0.1:6379> exit				#断开redis连接

五、基本操作命令

PS:可以借助官方文档:
https://docs.redis.com/

[root@template utils]# redis-cli
127.0.0.1:6379> ping
PONG
127.0.0.1:6379>

# set命令,存数据,给变量赋值(x:99)【给单个变量赋值】
127.0.0.1:6379> set x 99

# mset命令,存数据,给变量赋值(i:77),(j:88),(k:99)【给多个变量同时赋值】
127.0.0.1:6379> mset i 77 j 88 k 99

# get命令,取数据,获取变量i的值【获取单个变量的值】
127.0.0.1:6379> get i

# mget命令,取数据,获取多个变量j,k,x的值【获取多个变量的值】
127.0.0.1:6379> mget i j k
1) "77"
2) "88"
3) "99"

# keys命令,显示所有的变量名【* 代表所有】
127.0.0.1:6379> keys *
1) "j"
2) "i"
3) "k"
4) "x"

# keys命令,显示变量名为一个字符的变量【? 代表单个字符】
127.0.0.1:6379> keys ?
1) "j"
2) "i"
3) "k"
4) "x"

# keys命令,显示变量名为六个字符的变量【? 代表单个字符】
127.0.0.1:6379> keys ??????

# keys命令,显示age的变量名,不存在,即为空
127.0.0.1:6379> keys age
(empty list or set)

# keys命令,显示school的变量名,存在
127.0.0.1:6379> keys school

# type命令,查看变量i的类型【string 为字符类型】
127.0.0.1:6379> type i
string

# set命令,存数据,给z赋值(z:10)【给单个变量赋值】
127.0.0.1:6379> set z 10

# type命令,查看变量z的类型【string 为字符类型】
127.0.0.1:6379> type z
string

# lpush命令,存数据,给变量赋值(hostname:pc99,pc88)【列表类型】
127.0.0.1:6379> lpush hostname pc99 pc88
(integer) 2

# type命令,查看变量hostname的类型【list 为列表类型】
127.0.0.1:6379> type hostname
list

# exists命令,检查变量是否存在,重复给一个变量赋值,会覆盖上一次变量的值
返回值为1,代表变量存在;返回值为0,则代表变量不存在
127.0.0.1:6379> exists hostname
(integer) 1

注意:在redis中,使用set和mset存入的数据,数据类型都是字符类型

# keys命令,查看redis中所有的变量
127.0.0.1:6379> keys *

# ttl命令,查看变量有效期,-1 为永不过期【不重启redis服务和清空内存的情况下】
127.0.0.1:6379> ttl j
(integer) -1

# expire命令,设置变量j的有效期为20秒
127.0.0.1:6379> expire j 20
(integer) 1

# ttl命令,查看变量有效期,16秒
127.0.0.1:6379> ttl j
(integer) 16

# ttl命令,查看变量有效期,当变量的有效期为-2时,到期,该变量会被删除
127.0.0.1:6379> ttl j
(integer) -2

# exists命令,检查变量是否存在,变量被删除,返回值为1,代表变量存在;返回值为0,则代表变量不存在
127.0.0.1:6379> exists j
(integer) 0

# select命令,切换库,切换到编号3的库下
127.0.0.1:6379> select 3

# 当前处于编号3库下, select命令,切换到编号1的库下
127.0.0.1:6379[3]> select 1
OK

# 当前处于编号1库下, select命令,切换到编号0的库下
127.0.0.1:6379[1]> select 0
OK

# 当前处于编号0库下,查看0库下所有的变量
127.0.0.1:6379> keys *

# 将编号0库下的变量school,移动到编号1的库下
127.0.0.1:6379> move school 1
(integer) 1

# 将编号0库下的变量x,移动到编号2的库下
127.0.0.1:6379> move x 2
(integer) 1

# 编号0库下,变量school和变量x消失
127.0.0.1:6379> keys *

# 查看编号1库下的所有变量,变量"school"移动过来了
127.0.0.1:6379> select 1
127.0.0.1:6379[1]> keys *

# 查看编号2库下的所有变量,变量"x"移动过来了
127.0.0.1:6379[1]> select 2
127.0.0.1:6379[2]> keys *

# save命令,把内存中的数据保存到硬盘中
127.0.0.1:6379[2]> save
127.0.0.1:6379[2]> exit

# 执行save命令后,内存中的数据被保存到下面文件中
[root@template utils]# ls /var/lib/redis/6379/
dump.rdb

# 连接数据库 redis-cli 
# del命令,删除变量i
127.0.0.1:6379> del i
(integer) 1

# flushdb命令,删除当前所在库下的所有数据
127.0.0.1:6379> flushdb

# 查看编号0库下的所有变量,为空
127.0.0.1:6379> keys *

127.0.0.1:6379> select 1		#切换到其他库下,还有数据
127.0.0.1:6379[1]> keys *

# flushall命令,删除redis所有库下的数据
127.0.0.1:6379[0]> flushall
127.0.0.1:6379[1]> keys *
127.0.0.1:6379[1]> select 2
127.0.0.1:6379[2]> keys *
# shutdown停止服务
127.0.0.1:6379[2]> shutdown
not connected> exit
[root@template utils]]# ss -antlp | gre p6379

六、配置文件解析

1、查看redis配置文件信息,修改配置时,在对应的模块下写即可

[root@template ~]# vim /etc/redis/6379.conf

                             .........
############################# INCLUDES ##############################
                             .........
############################# MODULES ##############################
                             .........
############################# NETWORK ##############################
                             .........
############################# GENERAL ##############################

2、数据单位,不区分字母大小写

[root@template ~]# vim /etc/redis/6379.conf
# 1k => 1000 bytes
# 1kb => 1024 bytes
# 1m => 1000000 bytes
# 1mb => 1024*1024 bytes
# 1g => 1000000000 bytes
# 1gb => 1024*1024*1024 bytes

3、查看redis常用配置信息

守护进程:指进程会一直存在,等待用户访问(耗资源,客户端访问速度快)
非守护进程:当服务运行后,如果一段时间内没有用户访问,服务会进入到休眠状态;当有用户访问时,服务会被唤醒,供用户去访问(节省资源,客户端访问速度慢一些)

  70 bind 127.0.0.1				#指定客户访问的IP地址,这里只允许本机访问
  93 port 6379					#指定redis的访问端口
 137 daemonize yes				#以守护进程方式运行(进程一直存在,等待用户访问)
 172 logfile /var/log/redis_6379.log	#记录redis运行的启动和运行过程中的信息
 187 databases 16					#数据库个数,默认16个,可以修改
 264 dir /var/lib/redis/6379			#定义数据库目录
 533 # maxclients 10000			#客户端同时访问redis的并发数量,默认10000

4、内存管理,查看内存清除策略

.........
 562 # MAXMEMORY POLICY: how Redis will select what to remove when maxmemory
 563 # is reached. You can select among five behaviors:
 564 #
 565 # volatile-lru ->  向redis中存入数据时,数据已满,则会在设置了TTL过期时间的变量中选择,删除最近最少使用的key,用于存放新的key;
 
 566 # allkeys-lru ->   向redis中存入数据时,数据已满,则会在所有的变量中选择,删除最近最少使用的key,用于存放新的key;

 567 # volatile-lfu ->  向redis中存入数据时,数据已满,则会在设置了TTL过期时间的变量中选择,删除使用频率最少的key,用于存放新的key;

 568 # allkeys-lfu ->   向redis中存入数据时,数据已满,则会在所有的变量中选择,删除使用频率最少的key,用于存放新的key;

 569 # volatile-random ->  向redis中存入数据时,数据已满,则会在设置了TTL过期时间的变量中选择,随机删除key,用于存放新的key;
 
 570 # allkeys-random ->   向redis中存入数据时,数据已满,则会在所有的变量中选择,随机删除key,用于存放新的key;
 
 571 # volatile-ttl ->  向redis中存入数据时,数据已满,删除最近过期的key;
 572 # noeviction ->    向redis中存入数据时,数据已满,显示报错提示;   

5、内存优化设置,

从物理内存中划分多少内存给redis使用,这里没有指定,则代表将本机的所有物理内存交给redis去使用

560 # maxmemory <bytes> 
 
#maxmemory-policy 定义当内存空间不足时,删除已存储数据的方式,策略为 noeviction,即,即使内存使用完了,也不删除已存储的数据 
591 # maxmemory-policy noeviction   
 
#当使用lru,lfu,ttl 策略时,需要指定key模板的个数
602 # maxmemory-samples 5

6、修改redis运行参数,修改密码,IP地址和端口号

[root@template ~]# /etc/init.d/redis_6379 stop		#停掉redis的服务
[root@template ~]# vim /etc/redis/6379.conf
70 bind 192.168.4.50		#如果想让其他主机访问本机,修改监听地址为本机网卡地址
93 port 6350   			#修改端口号为6350
501 requirepass 123456		#取消注释,修改用户连接redis的密码为123456

启动redis的服务
[root@template ~]# /etc/init.d/redis_6379 start
查看redis服务的端口号
[root@template ~]# ss -ntulp | grep redis

七、连接redis服务

方法一:登录redis以后,输入连接密码

-h 指定要连接的主机,-p(小写) 指定连接端口号

[root@template ~]# redis-cli -h 192.168.4.50 -p 6350 
192.168.4.50:6350> auth 123456		#auth 后跟上连接密码,否则无法正常使用
192.168.4.50:6350> ping
192.168.4.50:6350> exit

方法二:连接redis时,输入连接密码
-a 指定连接密码

[root@template ~]# redis-cli -h 192.168.4.50  -p 6350 -a 123456
192.168.4.50:6350> ping
192.168.4.50:6350> exit

八、停止redis服务

当修改了redis服务的IP地址,密码和端口号以后,则无法通过脚本来停止redis服务
脚本停止服务针对的是redis服务默认的IP地址,密码和端口号

连接上redis, 使用shutdown来停止服务

[root@template ~]# redis-cli -h 192.168.4.50  -p 6350 -a 123456 shutdown
[root@template ~]# ss -ntulp | grep redis
[root@template ~]# /etc/init.d/redis_6379 start		#启动服务

1、面试题介绍

以前面试,面试官问了一个问题,大意是:

我们在终端中,通过执行
python main.py
命令,会启动一台前台进程直到程序结束。现在我还是想通过执行
python main.py
,启动一个后台进程,让后台进程运行我们的业务逻辑。这个时候应该怎么做呢?

回答上面这道题,需要先了解什么是前台进程和后台进程,什么是孤儿进程和僵尸进程?接下来,我们先一起看看前台进程和后台进程,以及孤儿进程和僵尸进程。最后再通过编写代码来完成面试题的需求。

2、前台进程和后台进程

2.1 什么是前台进程

在 Linux 中,
前台进程是指当前正在运行的进程,它与用户交互并占用终端
。当用户在终端中输入命令时,该命令所启动的进程就是前台进程。

前台进程会占用终端
,直到它执行完毕或者被中断(例如按下 Ctrl+C)。在前台进程运行期间,用户可以通过键盘输入命令或者发送信号来与进程交互。

2.2 什么是后台进程

Linux
后台进程是指在终端中运行的进程,但是不会占用终端的输入输出,而是在后台运行

在 Linux 中,可以通过在命令后面加上 & 符号来将进程放到后台运行。例如,运行命令
command &
就可以将
command
进程放到后台运行。

后台进程可以在终端关闭后继续运行,也可以同时运行多个后台进程。可以使用
jobs
命令查看当前运行的后台进程,使用
fg
命令将后台进程切换到前台运行,使用
bg
命令将前台进程切换到后台运行。

需要注意的是,后台进程在另外的终端是查看不到任何信息的,如果需要查看进程的输出信息,一般是将输出重定向到文件中,然后使用
tail
命令实时查看输出。

2.3 前台进程、后台进程如何切换

在 Linux 中,可以使用以下命令将前台进程切换到后台进程:

  1. 使用 Ctrl + Z 将当前正在运行的前台进程挂起。
  2. 使用
    bg
    命令将挂起的进程切换到后台运行。

使用
command &
,这样的方式是一开始就将
command
进程放到后台运行。

我们通过下面这个例子,来感受下前台进程和后台进程是如何切换的。

1、编写
test.py
文件。

import os
import time


def main():
    a = 1
    while True:
        time.sleep(1)
        a += 1
        print("a---->", a)
        if a > 30:
            break


if __name__ == '__main__':
    main()

2、通过
python test.py
执行程序。然后使用
ctrl + Z
是程序暂停到后台。注意,这个时候程序不会再往下执行了。

  • 如果想程序继续往下执行,使用
    bg
    命令将其切换到后台运行。

  • 还有一种方式是:在最开始执行命令的时候,加上
    &
    。即
    python main.py &

python test.py
a----> 2
a----> 3
^Z
[1]  + 1761 suspended  python test.py
sample_test [master●] %

3、通过
jobs
命令查询后台进程。

sample_test [master●] % jobs          
[1]  + suspended  python test.py
sample_test [master●] %

4、使用
fg
命令将指定的后台进程切换到前台运行。(
fg %N
,这里的N是
jobs
返回的序号,并不是进程的PID)

    
sample_test [master●] % fg %1     
[1]  + 1761 continued  python test.py
a----> 4
a----> 5
a----> 6
a----> 7

3、孤儿进程和僵尸进程

通过上面的讲解,我们知道了什么是后台进程和前台进程。接下来,我们再讲讲什么是孤儿进程和僵尸进程。

3.1 什么是孤儿进程

孤儿进程
是指
父进程已经结束或者异常退出,而子进程还在运行的情况下,子进程就会变成孤儿进程
。孤儿进程会被操作系统的
init进程接管

init进程
会成为孤儿进程的新的父进程,并负责回收孤儿进程的资源,避免资源泄露和浪费。

因此一般来说,孤儿进程并不会有什么危害。

我们来看一个关于孤儿进程的例子:

在main函数中,创建子进程,然后让父进程睡眠1s,让子进程先运行打印出其进程id(pid)以及父进程id(ppid);随后子进程睡眠3s(此时会调度到父进程运行直至结束),目的是让父进程先于子进程结束,让子进程有个孤儿的状态;最后子进程再打印出其进程id(pid)以及父进程id(ppid);观察两次打印 其父进程id(ppid)的区别。

import os
import time


def main():
    pid = os.fork()  # 创建子进程
    if pid < 0:
        # 创建失败
        print("fork failed!")
        exit(1)
    elif pid == 0:  # 子进程
        print("I am the child process.")
        print("pid:%d, ppid:%d " % (os.getpid(), os.getppid()))
        # 子进程睡眠3s,保证父进程先退出,此后子进程成为孤儿进程
        time.sleep(3)
        # 注意查看孤儿进程的父进程变化
        print("after sleep, pid:%d, ppid:%d" % (os.getpid(), os.getppid()))
        assert os.getppid() == 1
        print("child process is exited.")
    else:
        print("I am father process.")
        # 为保证子进程先运行,让父进程睡眠1s
        time.sleep(1)
        print("father process is exited.")




if __name__ == '__main__':
    main()

执行结果:

运行结果表明:当父进程结束后,子进程成为了孤儿进程。因为它的父进程id(ppid)变成了1,即init进程成为该子进程的父进程了。

3.2 什么是僵尸进程

Linux
僵尸进程是指已经结束执行的进程,但是其父进程还没有对其进行处理,导致该进程的进程描述符仍然存在于系统中
,这种进程被称为
僵尸进程

僵尸进程不会占用系统资源,但是如果大量的僵尸进程存在,会占用系统的进程描述符资源,导致系统进程描述符资源耗尽,从而导致系统崩溃。

为了避免僵尸进程的出现,父进程需要及时对其进行处理,可以使用
wait() 或 waitpid()
等系统调用来等待子进程结束并回收其资源。另外,也可以使用信号处理机制,在父进程中注册 SIGCHLD 信号处理函数来处理子进程结束的信号。

关于
僵尸进程的例子

在main函数中,创建子进程,然后让父进程睡眠30s,让子进程先终止(注意和孤儿进程例子的区别);这里子进程结束后父进程没有调用wait/waitpid函数获取其状态,用ps查看进程状态可以看出子进程为僵尸状态。

import os
import time


def main():
    pid = os.fork()  # 创建子进程
    if pid < 0:
        # 创建失败
        print("fork failed!")
        exit(1)
    elif pid == 0:  # 子进程
        print("I am the child process.I am exited.")
        exit(0)
    else:
        print("I am father process.")
        #  父进程睡眠30s等待子进程退出,且没有调用wait/waitpid获取其状态
        #  子进程会成为僵尸进程
        time.sleep(30)
        print("father process is exited.")




if __name__ == '__main__':
    main()

开一个终端,在终端是运行
test.py

在子进程结束,父进程睡眠(还没退出)的时候,再开一个终端用PS查看进程状态。

注意:

  1. 任何一个子进程(init除外)在exit()之后,其实它并没有真正的被销毁,而是留下一个称为僵尸进程(Zombie)的数据结构,等待父进程处理。
    这是每个子进程在结束时都要经过的阶段。
  2. 如果子进程在exit()之后,父进程没有来得及处理,这时用ps命令就能看到子进程的状态是“Z”。如果父进程能及时处理,可能用ps命令就来不及看到子进程的僵尸状态,但这并不等于子进程不经过僵尸状态。
  3. 如果父进程在子进程结束之前退出,则子进程将由init接管。init将会以父进程的身份对僵尸状态的子进程进行处理。
  4. 如果父进程是一个循环,不会结束,那么子进程就会一直保持僵尸状态,这就是系统中有时候会有很多僵尸进程的原因。

那么如何杀死僵尸进程呢,可以查询
僵尸进程与孤儿进程
链接,来进行学习,这里就不再讲述。

3.3 总结

  • 孤儿进程:父进程已亡,子进程成为孤儿,被init进程收养,由init进程对它们完成状态收集工作,因此一般没有坏的影响。
  • 僵尸进程:子进程已亡,父进程没给子进程收尸,子进程成为僵尸进程,占用系统资源。

4、面试题解决方式

现在再回过头来看 面试题的要求:

在终端中执行
python main.py
命令,启动后台进程来进行业务处理。

那么我们可以利用孤儿进程的特性,完成上面的需求。

1、通过
os.fork()
创建子进程。

2、创建完成后,让父进程退出,子进程继续运行。

简单案例:

import os
import time


def main():
    pid = os.fork()  # 创建子进程
    a = 0
    if pid < 0:
        # 创建失败
        print("fork failed!")
        exit(1)
    elif pid == 0:  # 子进程
        for i in range(10):
            time.sleep(1)
            a += i
        print("child process calculate result: %d" % a)
    else:
        print("I am father process.")
        exit(0)


if __name__ == '__main__':
    main()
sample_test [master●] % python test.py
I am father process.


10秒钟过后
sample_test [master●] % child process calculate result: 45

参考资料:

僵尸进程与孤儿进程