CountDownLatch
是 Java 并发包(
java.util.concurrent
)中的一个同步辅助类,它允许一个或多个线程等待一组操作完成。

一、设计理念

CountDownLatch
是基于 AQS(AbstractQueuedSynchronizer)实现的。其核心思想是
维护一个倒计数
,每次倒计数减少到零时,等待的线程才会继续执行。它的主要设计目标是允许多个线程协调完成一组任务。

1. 构造函数与计数器

public CountDownLatch(int count) {
    if (count < 0) throw new IllegalArgumentException("count < 0");
    this.sync = new Sync(count);
}

构造
CountDownLatch
时传入的
count
决定了计数器的初始值。该计数器控制了线程的释放。

2. AQS 支持的核心操作

AQS 是
CountDownLatch
的基础,通过自定义内部类
Sync
实现,
Sync
继承了 AQS 并提供了必要的方法。以下是关键操作:

  • acquireShared(int arg)
    : 如果计数器值为零,表示所有任务已完成,线程将获得许可。
  • releaseShared(int arg)
    : 每次调用
    countDown()
    ,会减少计数器,当计数器降到零时,AQS 将释放所有等待的线程。

3. 实现细节

  • countDown()
    :调用
    releaseShared()
    减少计数器,并通知等待线程。
  • await()
    :调用
    acquireSharedInterruptibly(1)
    ,如果计数器非零则阻塞等待。

二、底层原理

CountDownLatch
的核心是基于
AbstractQueuedSynchronizer
(AQS)来管理计数器状态的。AQS 是 JUC 中许多同步工具的基础,通过一个独占/共享模式的同步队列实现线程的管理和调度。
CountDownLatch
采用 AQS 的
共享锁机制
来控制多个线程等待一个条件。

1. AQS 的共享模式

AQS 设计了两种同步模式:
独占模式
(exclusive)和
共享模式
(shared)。
CountDownLatch
使用共享模式:

  • 独占模式
    :每次只能一个线程持有锁,如
    ReentrantLock
  • 共享模式
    :允许多个线程共享锁状态,如
    Semaphore

    CountDownLatch

CountDownLatch

await()

countDown()
方法对应于 AQS 的
acquireShared()

releaseShared()
操作。
acquireShared()
会检查同步状态(计数器值),若状态为零则立即返回,否则阻塞当前线程,进入等待队列。
releaseShared()
用于减少计数器并唤醒所有等待线程。

2. Sync 内部类的设计

CountDownLatch
通过一个私有的内部类
Sync
来实现同步逻辑。
Sync
继承自
AQS
,并重写
tryAcquireShared(int arg)

tryReleaseShared(int arg)
方法。

static final class Sync extends AbstractQueuedSynchronizer {
    Sync(int count) {
        setState(count);
    }

    protected int tryAcquireShared(int acquires) {
        return (getState() == 0) ? 1 : -1;
    }

    protected boolean tryReleaseShared(int releases) {
        // 自旋减计数器
        for (;;) {
            int c = getState();
            if (c == 0)
                return false;
            int nextc = c - 1;
            if (compareAndSetState(c, nextc))
                return nextc == 0;
        }
    }
}
  • tryAcquireShared(int)
    :当计数器为零时返回 1(成功获取锁),否则返回 -1(阻塞)。
  • tryReleaseShared(int)
    :每次
    countDown()
    减少计数器值,当计数器到达零时返回
    true
    ,唤醒所有阻塞线程。

3. CAS 操作确保线程安全

tryReleaseShared
方法使用 CAS(compare-and-set)更新计数器,避免了锁的开销。CAS 操作由 CPU 原语(如
cmpxchg
指令)支持,实现了高效的非阻塞操作。这种设计保证了
countDown()
的线程安全性,使得多个线程能够并发地减少计数器。

4. 内部的 ConditionObject

CountDownLatch
不支持复用,因为 AQS 的
ConditionObject
被设计为单一触发模式。计数器一旦降至零,
CountDownLatch
无法重置,只能释放所有线程,而不能再次设置初始计数器值。这就是其不可复用的根本原因。

三、应用场景

  1. 等待多线程任务完成

    CountDownLatch
    常用于需要等待一组线程完成其任务后再继续的场景,如批处理任务。
  2. 并行执行再汇总
    :在某些数据分析或计算密集型任务中,将任务分割成多个子任务并行执行,主线程等待所有子任务完成后再汇总结果。
  3. 多服务依赖协调
    :当一个服务依赖多个其他服务时,可以使用
    CountDownLatch
    来同步各个服务的调用,并确保所有依赖服务准备好之后再执行主任务。

四、示例代码

以下示例展示如何使用
CountDownLatch
实现一个并发任务等待所有子任务完成的机制。

import java.util.concurrent.CountDownLatch;

public class CountDownLatchExample {
    private static final int TASK_COUNT = 5;
    private static CountDownLatch latch = new CountDownLatch(TASK_COUNT);

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < TASK_COUNT; i++) {
            new Thread(new Task(i + 1, latch)).start();
        }
        
        // 主线程等待所有任务完成
        latch.await();
        System.out.println("所有任务已完成,继续主线程任务");
    }

    static class Task implements Runnable {
        private final int taskNumber;
        private final CountDownLatch latch;

        Task(int taskNumber, CountDownLatch latch) {
            this.taskNumber = taskNumber;
            this.latch = latch;
        }

        @Override
        public void run() {
            try {
                System.out.println("子任务 " + taskNumber + " 开始执行");
                Thread.sleep((int) (Math.random() * 1000)); // 模拟任务执行时间
                System.out.println("子任务 " + taskNumber + " 完成");
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                latch.countDown(); // 完成一个任务,计数器减一
            }
        }
    }
}

五、与其他同步工具的对比

1. CyclicBarrier

原理和用途

  • CyclicBarrier
    也允许一组线程相互等待,直到所有线程到达屏障位置(barrier point)。
  • 它适合用于
    多阶段任务

    分阶段汇聚
    ,如处理分块计算时每阶段汇总结果。

底层实现

  • CyclicBarrier
    内部通过
    ReentrantLock

    Condition
    实现,屏障次数可以重置,从而支持循环使用。

与 CountDownLatch 的对比

  • CyclicBarrier

    可复用性
    使其适合重复的同步场景,而
    CountDownLatch
    是一次性的。
  • CountDownLatch
    更灵活,允许任意线程调用
    countDown()
    ,适合分布式任务。
    CyclicBarrier
    需要指定的线程达到屏障。

2. Semaphore

原理和用途

  • Semaphore
    主要用于
    控制资源访问
    的并发数量,如限制数据库连接池的访问。

底层实现

  • Semaphore
    基于 AQS 的共享模式实现,类似于
    CountDownLatch
    ,但允许通过指定的“许可证”数量控制资源。

与 CountDownLatch 的对比

  • Semaphore
    可以动态增加/减少许可,而
    CountDownLatch
    只能递减。
  • Semaphore
    适合控制访问限制,而
    CountDownLatch
    用于同步点倒计数。

3. Phaser

原理和用途

  • Phaser

    CyclicBarrier
    的增强版,允许动态调整参与线程的数量。
  • 适合多阶段任务同步,并能随时增加或减少参与线程。

底层实现

  • Phaser
    内部包含一个计数器,用于管理当前阶段的参与线程,允许任务动态注册或注销。

与 CountDownLatch 的对比

  • Phaser
    更适合复杂场景,能够灵活控制阶段和参与线程;
    CountDownLatch
    的结构简单,只能用于一次性同步。
  • Phaser
    的设计更复杂,适合长时间、多线程协调任务,而
    CountDownLatch
    更适合简单任务等待。

4、总结

CountDownLatch
是一个轻量级、不可复用的倒计数同步器,适合简单的一次性线程协调。其基于 AQS 的共享锁实现使得线程等待和计数器更新具有高效的并发性。虽然
CountDownLatch
不具备重用性,但其设计简洁,尤其适合需要等待多线程任务完成的场景。

与其他 JUC 工具相比:

  • CyclicBarrier
    更适合多阶段同步、阶段性汇总任务。
  • Semaphore
    适合资源访问控制,具有可控的许可量。
  • Phaser
    灵活性更高,适合动态参与线程、复杂多阶段任务。

选择适合的同步工具,取决于任务的性质、线程参与动态性以及是否需要重用同步控制。

本文原文地址:
GoLang协程Goroutiney原理与GMP模型详解

什么是goroutine

Goroutine是Go语言中的一种轻量级线程,也成为协程,由Go运行时管理。它是Go语言并发编程的核心概念之一。Goroutine的设计使得在Go中实现并发编程变得非常简单和高效。

以下是一些关于Goroutine的关键特性:

  • 轻量级:Goroutine的创建和切换开销非常小。与操作系统级别的线程相比,Goroutine占用的内存和资源更少。一个典型的Goroutine只需要几KB的栈空间,并且栈空间可以根据需要动态增长。
  • 并发执行:Goroutine可以并发执行多个任务。Go运行时会自动将Goroutine调度到可用的处理器上执行,从而充分利用多核处理器的能力。
  • 简单的语法:启动一个Goroutine非常简单,只需要在函数调用前加上go关键字。例如,go myFunction()会启动一个新的Goroutine来执行myFunction函数。
  • 通信和同步:Go语言提供了通道(Channel)机制,用于在Goroutine之间进行通信和同步。通道是一种类型安全的通信方式,可以在不同的Goroutine之间传递数据。

什么是协程

协程(Coroutine)是一种比线程更轻量级的并发编程方式。它允许在单个线程内执行多个任务,并且可以在任务之间进行切换,而不需要进行线程上下文切换的开销。协程通过协作式多任务处理来实现并发,这意味着任务之间的切换是由程序显式控制的,而不是由操作系统调度的。

以下是协程的一些关键特性:

  • 轻量级:协程的创建和切换开销非常小,因为它们不需要操作系统级别的线程管理。
  • 非抢占式:协程的切换是显式的,由程序员在代码中指定,而不是由操作系统抢占式地调度。
  • 状态保存:协程可以在暂停执行时保存其状态,并在恢复执行时继续从暂停的地方开始。
  • 异步编程:协程非常适合用于异步编程,特别是在I/O密集型任务中,可以在等待I/O操作完成时切换到其他任务,从而提高程序的并发性和效率。

Goroutin就是Go在协程这个场景上的实现。

以下是一个简单的go goroutine例子,展示了如何使用协程:

package main

import (
	"fmt"
	"sync"
	"time"
)

// 定义一个简单的函数,模拟一个耗时操作
func printNumbers(wg *sync.WaitGroup) {
	defer wg.Done() // 在函数结束时调用Done方法
	for i := 1; i <= 5; i++ {
		fmt.Printf("Number: %d\n", i)
		time.Sleep(1 * time.Second) // 模拟耗时操作
	}
}

func main() {
	var wg sync.WaitGroup

	// 启动一个goroutine来执行printNumbers函数
	wg.Add(1)
	go printNumbers(&wg)

	// 主goroutine继续执行其他操作
	for i := 'A'; i <= 'E'; i++ {
		fmt.Printf("Letter: %c\n", i)
		time.Sleep(1 * time.Second) // 模拟耗时操作
	}

	// 等待所有goroutine完成
	wg.Wait()
}

我们定义了一个名为printNumbers的函数,该函数会打印数字1到5,并在每次打印后暂停1秒。然后,在main函数中,我们使用go关键字启动一个新的goroutine来执行printNumbers函数。同时,主goroutine继续执行其他操作,打印字母A到E,并在每次打印后暂停1秒。

需要注意的是,主goroutine和新启动的goroutine是并发执行的。为了确保所有goroutine完成,我们使用sync.WaitGroup来等待所有goroutine完成。我们在启动goroutine之前调用wg.Add(1),并在printNumbers函数结束时调用wg.Done()。最后,我们在main函数中调用wg.Wait(),等待所有goroutine完成。这样可以确保程序在所有goroutine完成之前不会退出。

协程是一种强大的工具,可以简化并发编程,特别是在处理I/O密集型任务时。

Goroutin实现原理

Goroutine的实现原理包括Goroutine的创建、调度、上下文切换和栈管理等多个方面。通过GPM模型和高效的调度机制,Go运行时能够高效地管理和调度大量的Goroutine,实现高并发编程。

Goroutine的创建

当使用go关键字启动一个新的Goroutine时,Go运行时会执行以下步骤:

  1. 分配G结构体:Go运行时会为新的Goroutine分配一个G结构体(G表示Goroutine),其中包含Goroutine的状态信息、栈指针、程序计数器等。
  2. 分配栈空间:Go运行时会为新的Goroutine分配初始的栈空间,通常是几KB。这个栈空间是动态增长的,可以根据需要自动扩展。
  3. 初始化G结构体:Go运行时会初始化G结构体,将Goroutine的入口函数、参数、栈指针等信息填入G结构体中。
  4. 将Goroutine加入调度队列:Go运行时会将新的Goroutine加入到某个P(Processor)的本地运行队列中,等待调度执行。

Goroutine的调度

Go运行时使用GPM模型(Goroutine、Processor、Machine)来管理和调度Goroutine。调度过程如下:

  • P(Processor):P是Go运行时的一个抽象概念,表示一个逻辑处理器。每个P持有一个本地运行队列,用于存储待执行的Goroutine。P的数量通常等于机器的CPU核心数,可以通过runtime.GOMAXPROCS函数设置。
  • M(Machine):M表示一个操作系统线程。M负责实际执行P中的Goroutine。M与P是一对一绑定的关系,一个M只能绑定一个P,但一个P可以被多个M绑定(通过抢占机制)。M的数量是由Go运行时系统动态管理和确定的。M的数量并不是固定的,而是根据程序的运行情况和系统资源的使用情况动态调整的。通过runtime.NumGoroutine()和runtime.NumCPU()函数,我们可以查看当前的Goroutine数量和CPU核心数。Go运行时对M的数量有一个默认的最大限制,以防止创建过多的M导致系统资源耗尽。这个限制可以通过环境变量GOMAXPROCS进行调整,但通常不需要手动设置。
  • G(Goroutine):代表一个goroutine,它有自己的栈,instruction pointer和其他信息(正在等待的channel等等),用于调度。
  • 调度循环:每个P会在一个循环中不断从本地运行队列中取出Goroutine,并将其分配给绑定的M执行。如果P的本地运行队列为空,P会尝试从其他P的本地运行队列中窃取Goroutine(工作窃取机制)。

    从上图中看,有2个物理线程M,每一个M都拥有一个处理器P,每一个也都有一个正在运行的goroutine。P的数量可以通过GOMAXPROCS()来设置,它其实也就代表了真正的并发度,即有多少个goroutine可以同时运行。图中灰色的那些goroutine并没有运行,而是出于ready的就绪态,正在等待被调度。P维护着这个队列(称之为runqueue),Go语言里,启动一个goroutine很容易:go function 就行,所以每有一个go语句被执行,runqueue队列就在其末尾加入一个goroutine,在下一个调度点,就从runqueue中取出(如何决定取哪个goroutine?)一个goroutine执行。

P的数量可以大于器的CPU核心数?

在Go语言中,P(Processor)的数量通常等于机器的CPU核心数,但也可以通过runtime.GOMAXPROCS函数进行调整。默认情况下,Go运行时会将P的数量设置为机器的逻辑CPU核心数。然而,P的数量可以被设置为大于或小于机器的CPU核心数,这取决于具体的应用需求和性能考虑。

调整P的数量,可以使用runtime.GOMAXPROCS函数来设置P的数量。例如:

package main

import (
	"fmt"
	"runtime"
	"sync"
)

func worker(id int, wg *sync.WaitGroup) {
	defer wg.Done()
	fmt.Printf("Worker %d starting\n", id)
	// 模拟工作负载
	for i := 0; i < 1000000000; i++ {
	}
	fmt.Printf("Worker %d done\n", id)
}

func main() {
	// 设置P的数量为机器逻辑CPU核心数的两倍
	numCPU := runtime.NumCPU()
	runtime.GOMAXPROCS(numCPU * 2)

	var wg sync.WaitGroup

	// 启动多个Goroutine
	for i := 1; i <= 10; i++ {
		wg.Add(1)
		go worker(i, &wg)
	}

	// 等待所有Goroutine完成
	wg.Wait()
	fmt.Println("All workers done")
}

在这个示例中,我们将P的数量设置为机器逻辑CPU核心数的两倍。这样做的目的是为了观察在不同P数量设置下程序的性能表现。

  • P的数量大于CPU核心数的影响
    • 上下文切换增加:当P的数量大于CPU核心数时,可能会导致更多的上下文切换。因为操作系统需要在有限的CPU核心上调度更多的线程(M),这可能会增加调度开销。
    • 资源竞争:更多的P意味着更多的Goroutine可以同时运行,但这也可能导致更多的资源竞争,特别是在I/O密集型任务中。过多的P可能会导致资源争用,反而降低程序的整体性能。
    • 并发性提高:在某些情况下,增加P的数量可以提高程序的并发性,特别是在存在大量阻塞操作(如I/O操作)的情况下。更多的P可以更好地利用CPU资源,减少阻塞时间。
  • P的数量小于CPU核心数的影响
    • CPU利用率降低:当P的数量小于CPU核心数时,可能会导致CPU资源未被充分利用。因为P的数量限制了同时运行的Goroutine数量,可能会导致某些CPU核心处于空闲状态。
    • 减少上下文切换:较少的P数量可以减少上下文切换的开销,因为操作系统需要调度的线程(M)数量减少。这可能会提高CPU密集型任务的性能。

选择合适的P数量选择合适的P数量需要根据具体的应用场景和性能需求进行调整。以下是一些建议:

  • CPU密集型任务:对于CPU密集型任务,通常将P的数量设置为等于或接近机器的逻辑CPU核心数,以充分利用CPU资源。
  • I/O密集型任务:对于I/O密集型任务,可以考虑将P的数量设置为大于CPU核心数,以提高并发性和资源利用率。
  • 性能测试和调优:通过性能测试和调优,找到最佳的P数量设置。可以尝试不同的P数量,观察程序的性能表现,选择最优的配置。

Goroutine的上下文切换

Goroutine的上下文切换由Go运行时的调度器管理,主要涉及以下步骤:

  • 保存当前Goroutine的状态:当一个Goroutine被挂起时,Go运行时会保存当前Goroutine的状态信息,包括程序计数器、栈指针、寄存器等。
  • 切换到新的Goroutine:Go运行时会从P的本地运行队列中取出下一个待执行的Goroutine,并恢复其状态信息。
  • 恢复新的Goroutine的状态:Go运行时会将新的Goroutine的状态信息加载到CPU寄存器中,并跳转到新的Goroutine的程序计数器位置,继续执行。

Goroutine什么时候会被挂起?Goroutine会在执行阻塞操作、使用同步原语、被调度器调度、创建和销毁时被挂起。Go运行时通过高效的调度机制管理Goroutine的挂起和恢复,以实现高并发和高性能的程序执行。了解这些挂起的情况有助于编写高效的并发程序,并避免潜在的性能问题。

  1. 阻塞操作

当Goroutine执行阻塞操作时,它会被挂起,直到阻塞操作完成。常见的阻塞操作包括:

  • I/O操作:如文件读写、网络通信等。
  • 系统调用:如调用操作系统提供的阻塞函数。
  • Channel操作:如在无缓冲Channel上进行发送或接收操作时,如果没有对应的接收者或发送者,Goroutine会被挂起。
  1. 同步原语

使用同步原语(如sync.Mutex、sync.WaitGroup、sync.Cond等)进行同步操作时,Goroutine可能会被挂起,直到条件满足。例如:

  • 互斥锁(Mutex):当Goroutine尝试获取一个已经被其他Goroutine持有的互斥锁时,它会被挂起,直到锁被释放。
  • 条件变量(Cond):当Goroutine等待条件变量时,它会被挂起,直到条件变量被通知。
  1. 调度器调度

Go运行时的调度器会根据需要挂起和恢复Goroutine,以实现高效的并发调度。调度器可能会在以下情况下挂起Goroutine:

  • 时间片用完:Go调度器使用协作式调度,当一个Goroutine的时间片用完时,调度器会挂起该Goroutine,并调度其他Goroutine执行。
  • 主动让出:Goroutine可以通过调用runtime.Gosched()主动让出CPU,调度器会挂起该Goroutine,并调度其他Goroutine执行。
  1. Goroutine的创建和销毁
  • 创建:当一个新的Goroutine被创建时,它会被挂起,直到调度器将其调度执行。
  • 销毁:当一个Goroutine执行完毕或被显式终止时,它会被挂起并从调度器中移除。

Goroutine的栈管理

Goroutine的栈空间是动态分配的,可以根据需要自动扩展。Go运行时使用分段栈(segmented stack)或连续栈(continuous stack)来管理Goroutine的栈空间:

  • 分段栈:在早期版本的Go中,Goroutine使用分段栈。每个Goroutine的栈由多个小段组成,当栈空间不足时,Go运行时会分配新的栈段并链接到现有的栈段上。
  • 连续栈:在Go 1.3及以后的版本中,Goroutine使用连续栈。每个Goroutine的栈是一个连续的内存块,当栈空间不足时,Go运行时会分配一个更大的栈,并将现有的栈内容复制到新的栈中。

书接上回,我们继续来分享一些关于时间转换的常用扩展方法。

01
、时间转日期时间 TimeOnly

该方式是把TimeOnly类型转为DateTime类型,其中日期部分使用系统当前日期,时间部分则使用TimeOnly,具体代码如下:

//时间转日期时间,默认使用当前日期+时间转为日期时间格式
public static DateTime ToDateTime(this TimeOnly timeOnly)
{
    return DateOnly.FromDateTime(DateTime.Now).ToDateTime(timeOnly);
}

02
、日期+时间转为日期时间 TimeOnly

该方法是直接对TimeOnly时间进行扩展,取用其时间,然后补全指定DateOnly日期,最后转为DateTime,代码如下:

//日期+时间转为日期时间
public static DateTime ToDateTime(this TimeOnly timeOnly, DateOnly dateOnly)
{
    return dateOnly.ToDateTime(timeOnly);
}

03
、日期时间中日期部分+时间转日期时间 TimeOnly

该方法是对TimeOnly时间进行扩展,取其时间,然后补全指定DateTime中的日期部分,最后再转为DateTime,代码如下:

//日期时间中日期部分+时间转日期时间格式
public static DateTime ToDateTime(this TimeOnly timeOnly, DateTime dateTime)
{
    return DateOnly.FromDateTime(dateTime).ToDateTime(timeOnly);
}

04
、日期转日期时间 DateOnly

该方式是把DateOnly类型转为DateTime类型,其中日期部分使用DateOnly,时间部分则使用系统当前时间,具体代码如下:

//日期转日期时间,日期+默认使用当前时间转为日期时间格式
public static DateTime ToDateTime(this DateOnly dateOnly)
{
    return dateOnly.ToDateTime(TimeOnly.FromDateTime(DateTime.Now));
}

05
、日期+日期时间中时间部分转日期时间 DateOnly

该方法是对DateOnly日期进行扩展,取其日期,然后补全指定DateTime中的时间部分,最后再转为DateTime,代码如下:

//日期+日期时间中时间部分转日期时间
public static DateTime ToDateTime(this DateOnly dateOnly, DateTime dateTime)
{
    return dateOnly.ToDateTime(TimeOnly.FromDateTime(dateTime));
}

06
、日期时间中日期部分+时间转日期时间 DateTime

该方法是对DateTime日期时间进行扩展,取其日期部分,然后补全指定TimeOnly时间,最后再转为DateTime,代码如下:

//日期时间中日期部分+时间转日期时间
public static DateTime ToDateTime(this DateTime dateTime, TimeOnly timeOnly)
{
    return DateOnly.FromDateTime(dateTime).ToDateTime(timeOnly);
}

07
、日期+日期时间中时间部分转为日期时间 DateTime

该方法是对DateTime日期时间进行扩展,取其时间部分,然后补全指定DateOnly日期,最后再转为DateTime,代码如下:

//日期+日期时间中时间部分转为日期时间
public static DateTime ToDateTime(this DateTime dateTime, DateOnly dateOnly)
{
    return dateOnly.ToDateTime(TimeOnly.FromDateTime(dateTime));
}

08
、日期时间转日期 DateTime

该方法是对DateTime日期时间进行扩展,取其日期部分转为DateOnly,代码如下:

//日期时间转日期,保留日期时间中日期部分
public static DateOnly ToDateOnly(this DateTime dateTime)
{
    return DateOnly.FromDateTime(dateTime);
}

09
、日期时间转时间 DateTime

该方法是对DateTime日期时间进行扩展,取其时间部分转为TimeOnly,代码如下:

//日期时间转时间,保留日期时间中时间部分
public static TimeOnly ToTimeOnly(this DateTime dateTime)
{
    return TimeOnly.FromDateTime(dateTime);
}

上面的这些扩展方法虽然大多都是简单调用原生方法,但是通过扩展方法方式提供出来后的确在编码上提升了一些效率,代码也会根据简洁。

10
、代码结构设计与组织

代码结构的设计和组织是软件开发过程中重要的一环,它将直接影响代码的可读性、可维护性、扩展性、测试性和团队协作的效率。

到目前为止我们关于时间的扩展方法已经有接近30个方法了,后面还有很多,目前都是放在DateTimeExtension类下面,如下图:

到这里已经能感受到代码结构显得很混乱了,直观感觉就是可读性差,没有结构化必然会导致扩展困难。

可能会有人提出不同观点,这些不就是很多静态方法吗,只有排版整齐就行了,其实不然,我们可以借助一些小技巧把方法集合结构化的管理起来。

结构化的管理一种实现方式就行——分类。下面我们就对现有代码进行分类。

分类是一门技术活,如何选择分类标准很关键,比如我们这里可以根据入参类型分为针对long类型转换、针对DateTime类型转换、针对string类型转换;也可以根据功能分类比如时间和时间戳互相转换是一类,时间和字符串互相转换是一类,DateTime与DateOnly和TimeOnly互相转换是一类等等分类方式。

1、分大类

我的思路是这样的,首先通过部分类partial把DateTimeExtension根据入参类型分成几个大类。

代码整理后效果如下:

2、分小类

分完大类后发现long类型中方法还是很大,因此我们继续对其进行分类,我们根据功能不同分为两类:转为本地日期时间和转为UTC日期时间。我们可以使用代码折叠的预处理指令#region和#endregion来处理处理小分类。

代码整理以后效果如下:

从一开始一堆方法到现在结构层次已经非常清晰了,阅读起来也非常方便,后期维护扩展就可以很容易的快速找到相应地方处理。

11
、单元测试

作为一个开源代码,我们首要任务应该保证代码正确性,因此单元测试是必不可少的。单元测试不但可以保证我们代码的正确性,还可以促使我们代码写的跟健壮。

单元测试我们以同样的分类方式进行代码组织,代码大致如下:

因为具体的测试用例都比较简单,这里就不拿出来讲解了。

测试用例编写的越完善,我们代码就越可靠,因此我们在编写单元测试时候,要尽可能覆盖所有逻辑,要尽可能覆盖到一些特例情况,我们想的越多代码质量才能越高。

12
、文档

作为一个开源代码,还需要一个好的文档,这样别人才能轻松使用,目前文档还是缺失的,后续我将陆续添加上。

当代码结构设计与组织、单元测试、文档这三部分都做好了,我感觉这个开源代码就达到开源给别人的最低要求了。后面的开源代码我都将围绕这三部分展开,希望可以给大家分享一些高质量的开源代码。

稍晚些时候我会把库上传至Nuget上,大家可以搜索Ideal.Core.Common直接使用。


:测试方法代码以及示例源码都已经上传至代码库,有兴趣的可以看看。
https://gitee.com/hugogoos/Ideal

大家好,我是木宛哥;在 10余年的工作经历让我深刻体会到软件开发不仅仅是写代码,更是一个系统化的交付过程。
为此我总结了20条编程感悟,涵盖了代码规范、设计原则、测试方法与交付流程等多个方面;​通过遵循代码规范,让代码更加可读与可维护,同时合理的设计能够有效应对需求变化,模块化的单元测试又确保了产品的可靠性,顾全的交付流程最后提升了项目质量。
希望这些感悟更多程序员提供参考,帮助大家在编程的道路上不断进步。

1. 清晰的命名

● 原则:代码应该易于阅读和理解;例如:变量、函数和类的名称应能清楚表达其意图;

● 示例:

// 明确表示学生数量
int numberOfStudents = 30; 

/**
 * 计算圆面积
 * @param radius 半径
 * @return 面积
 */
public double calculateAreaOfCircle(double radius) {
    return Math.PI * radius * radius;
}

2. 使用注释

● 原则:在复杂或重要的代码段添加注释,帮助他人理解;

● 示例:

/**
 * 计算给定列表的平均值
 *
 * @param numbers 要计算的数字列表
 * @return 返回数字的平均值,如果列表为空则返回0
 */
public static double calculateAverage(List<Double> numbers) {
    if (numbers == null || numbers.isEmpty()) {
        return 0;
    }

    double sum = 0.0; // 用于保存数字的总和
    int count = 0; // 用于记录有效数字的数量

    // 遍历列表中的每个数字并计算总和
    //【注意】:检查列表中的每个元素是否为 null,需要过滤
    for (Double num : numbers) {
        if (num != null) {
            sum += num; 
            count++;
        }
    }

    if (count == 0) {
        return 0;
    }
    double average = sum / count;
    return average;
}

3. 一致的编码风格

● 原则:遵循团队的编码标准,保持代码风格一致;

● 示例:使用统一的缩进和大括号位置。例如 IDEA 等 IDE 中配置统一的 CodeStyle:Alibaba-CodeStyle 、Google-CodeStyle 等;

4. 代码模块化

● 原则:将功能分解成小模块,增加重用性;

● 示例:

public class Calculator {
    /**
     * 加
     * @param a
     * @param b
     * @return
     */
    public int add(int a, int b) {
        return a + b;
    }

    /**
     * 减
     * @param a
     * @param b
     * @return
     */
    public int subtract(int a, int b) {
        return a - b;
    }
}

5. 避免重复代码

● 原则:遵循DRY原则(Don’t Repeat Yourself);

● 示例:

//不好的实践:重复
public class Calculator {
    public void addAndPrint(int a, int b) {
        int result = a + b;
        System.out.println("Result: " + result);
    }

    public void addAndPrintAnother(int x, int y) {
        int result = x + y;
        System.out.println("Result: " + result);
    }
}

//好的实践:我们可以提取出一个公共方法来遵循DRY原则:
public class Calculator {

    public void addAndPrint(int a, int b) {
        printResult(add(a, b));
    }

    public int add(int a, int b) {
        return a + b;
    }

    private void printResult(int result) {
        System.out.println("Result: " + result);
    }
}

6. 依赖接口而不是具体的实现

● 原则:依赖接口而不是具体的实现,增强灵活性;

● 示例:


public interface Shape {
    double area();
}

public class Circle implements Shape {
    private double radius;

    public Circle(double radius) {
        this.radius = radius;
    }

    @Override
    public double area() {
        return Math.PI * radius * radius;
    }
}

public class Square implements Shape {
    private double sideLength;

    public Square(double sideLength) {
        this.sideLength = sideLength;
    }

    @Override
    public double area() {
        return sideLength * sideLength;
    }
}

//依赖接口而不是具体的实现
void printf(Shape shape);

7. 避免魔法数字

● 原则:使用常量代替魔法数字;

● 示例:


final double FIXED_RATE = 3
double area = FIXED_NO * radius

8. 简化条件语句

● 原则:避免复杂的条件逻辑。用快速 return 来减少 if 嵌套层次;

● 示例:


//不推荐:嵌套太深
public void checkUser(User user) {
    if (user != null) {
        if (user.getAge() > 18) {
            if (user.isActive()) {
                // 允许访问
                System.out.println("Access granted");
            } else {
                System.out.println("User is not active");
            }
        } else {
            System.out.println("User is underage");
        }
    } else {
        System.out.println("User is null");
    }
}

//推荐:快速失败返回
public void checkUser(User user) {
    if (user == null) {
        System.out.println("User is null");
        return;
    }
    
    if (user.getAge() <= 18) {
        System.out.println("User is underage");
        return;
    }

    if (!user.isActive()) {
        System.out.println("User is not active");
        return;
    }

    // 允许访问
    System.out.println("Access granted");
}

9. 异常处理

● 原则:通过适当的异常处理提高程序的健壮性;

● 示例:


//异常
try {
    int result = 10 / 0;
} catch (ArithmeticException e) {
    log.error("非法参数,不能被零除");
}

//熔断
try (Entry entry = SphU.entry("resourceName")) {  
    // 你的业务逻辑  
} catch (BlockException ex) {  
    // 处理被阻止的请求  
}

10. 标准化错误日志处理

● 原则:统一错误处理的方式和日志记录。方便日志采集和告警配置;

● 示例:


public void logError(String message) {
    log.error("ERROR|Trace:{0}|Msg:{1} " Context.getTrace(), message);
}

11. 方法参数不宜过长

● 原则:方法参数应尽量少,避免混乱,超过3个推荐封装成模型;

● 示例:


//不推荐
void createUser(String name,int age,String email);


//推荐
public class UserService {
    public void createUser(User user) {
        
    }
}

class User {
    private String name;
    private int age;
    private String email;

    public User(String name, int age, String email) {
        this.name = name;
        this.age = age;
        this.email = email;
    }

    // Getters and Setters
}

12. 使用现有工具类来简化操作

● 原则:优先使用现有工具类 如
apache.commons
来简化操作

● 示例:


StringUtils.isNotEmpty("");
CollectionUtils.isNotEmpty()

13. 尽量使用不变的变量

● 原则:使用
final
关键字声明不可变的变量,提高代码的可靠性;

● 示例:


final int MAX_VALUE = 100;
ImmutableList.of();

14. 测试驱动开发(TDD)

● 原则:先写测试,再写代码,确保代码的可测试性;

● 示例:


@Test
public void testAdd() {
    Calculator calculator = new Calculator();
    assertEquals(5, calculator.add(2, 3));
}

15. 避免过度优化

● 原则:优先考虑代码的可读性,优化通常是在识别出性能问题后进行的;

16. 使用版本控制

● 原则:使用版本控制工具(git)管理代码变化;

17. 重视系分文档

● 原则:

○ 开发前,考虑清楚为什么要做这个需求。从背景及现状分析->为什么要做(why)->要做什么(what)->如何去做(how) 体系化思考;

○ 再从业务用例分析->系统依赖分析->领域模型分析->架构设计分析->时序图分析等落地最终的系分;

18. 重视代码评审

● 原则:定期进行代码评审,提高代码质量,提高团队研发意识;

19. 重视每一次交付

● 原则:

○ 事前锁定资源,上下游达成一致,明确里程碑计划;

○ 事中按需推进,每周项目进度同步,及时通晒风险;

○ 事后组织复盘以及关注业务数据(关注价值)

20.重视交付质量

● 原则:新功能需多考虑灰度验证

○ 后端服务:可按分组进行灰度验证(gray 分组->default 分组)

○ 客户端:小范围升级验证无问题后,逐步放量升级;

写在最后

欢迎关注我的公众号:编程启示录,第一时间获取最新消息;

微信 公众号
image image

上一篇:《人工智能——自然语言处理简介》

序言:
人工智能大语言模型(LLM)如何理解人类的自然语言?这个过程的核心在于将文本转化为计算机能处理的数值形式,经过计算,最终达到对语言的理解。起初,我们只是简单的随便用一个数字来表示一个单词或一个词根,但随着研究深入,我们发现,不同的数值表达方式能显著提高模型对语言的理解效果。因此,在构建大语言模型(LLM)时,关键的一步是将人类语言转化成合适的数值表示,以便模型能够接收、处理并生成有效的输出。好了,让我们进入正题。

与真实数据源协同工作

现在你已经了解了获取句子、用词索引进行编码以及对结果进行序列化的基本知识,你可以通过使用一些著名的公共数据集,将其通过工具转换为易于序列化的格式来进一步提升技能。我们将从TensorFlow Datasets中的IMDb数据集开始,它的大部分处理工作已经为你完成了。之后,我们将亲自动手处理一个基于JSON的数据集以及几个包含情感数据的逗号分隔值(CSV)数据集!

从TensorFlow Datasets获取文本

我们在第4章探索过TFDS,因此如果你对本节中的某些概念不熟悉,可以回顾一下。TFDS的目标是尽可能简单地以标准化方式访问数据。它提供了多个基于文本的数据集,我们将探索imdb_reviews,这是一个包含5万条电影评论的IMDb数据集,每条评论的情感被标注为正面或负面。

下面的代码将加载IMDb数据集的训练集并逐项迭代,将包含评论的文本字段添加到名为imdb_sentences的列表中。每条评论由文本和表示情感的标签组成。注意,将tfds.load调用包裹在tfds.as_numpy中,确保数据以字符串而不是张量的形式加载:

imdb_sentences = []

train_data = tfds.as_numpy(tfds.load('imdb_reviews', split="train"))

for item in train_data:

imdb_sentences.append(str(item['text']))

获得句子后,可以像之前一样创建一个分词器并对它们进行拟合,还可以创建一组序列:

tokenizer = tf.keras.preprocessing.text.Tokenizer(num_words=5000)

tokenizer.fit_on_texts(imdb_sentences)

sequences = tokenizer.texts_to_sequences(imdb_sentences)

你还可以打印出你的词索引以查看:

print(tokenizer.word_index)

词索引过大无法全部显示,但以下是前20个词。注意,分词器按数据集中词的频率排列,因此“the”、“and”和“a”等常用词被索引:

{'the': 1, 'and': 2, 'a': 3, 'of': 4, 'to': 5, 'is': 6, 'br': 7, 'in': 8, 'it': 9, 'i': 10, 'this': 11, 'that': 12, 'was': 13, 'as': 14, 'for': 15, 'with': 16, 'movie': 17, 'but': 18, 'film': 19, "'s": 20, ...}

这些是停用词,如上一节所述。由于这些词出现频率最高且缺乏独特性,它们的存在会影响训练准确性。

此外,注意“br”也在列表中,因为它在该语料库中常用作HTML标签

你可以更新代码,使用BeautifulSoup移除HTML标签,添加字符串转换以移除标点符号,并从给定列表中删除停用词,如下所示:

from bs4 import BeautifulSoup

import string

stopwords = ["a", ..., "yourselves"]

table = str.maketrans('', '', string.punctuation)

imdb_sentences = []

train_data = tfds.as_numpy(tfds.load('imdb_reviews', split="train"))

for item in train_data:

sentence = str(item['text'].decode('UTF-8').lower())

soup = BeautifulSoup(sentence)

sentence = soup.get_text()

words = sentence.split()

filtered_sentence = ""

for word in words:

word = word.translate(table)

if word not in stopwords:

filtered_sentence = filtered_sentence + word + " "

imdb_sentences.append(filtered_sentence)

tokenizer = tf.keras.preprocessing.text.Tokenizer(num_words=25000)

tokenizer.fit_on_texts(imdb_sentences)

sequences = tokenizer.texts_to_sequences(imdb_sentences)

print(tokenizer.word_index)

注意,在处理之前将句子转换为小写,因为所有的停用词都存储为小写。现在打印出的词索引如下所示

{'movie': 1, 'film': 2, 'not': 3, 'one': 4, 'like': 5, 'just': 6, 'good': 7, 'even': 8, 'no': 9, 'time': 10, 'really': 11, 'story': 12, 'see': 13, 'can': 14, 'much': 15, ...}

可以看到现在比之前干净了许多。不过,仍有改进空间,我注意到在查看完整索引时,一些不常见的词在末尾显得无意义。评论者经常将词组合在一起,比如用连字符(“annoying-conclusion”)或斜杠(“him/her”),移除标点会错误地将这些词合并为一个词。

你可以添加代码,在句子创建后立即在这些字符周围添加空格

sentence = sentence.replace(",", " , ")

sentence = sentence.replace(".", " . ")

sentence = sentence.replace("-", " - ")

sentence = sentence.replace("/", " / ")

这样,类似“him/her”这样的组合词会被转换为“him / her”,然后/被去掉,分词后会成为两个词。这样可能会带来更好的训练效果。

现在你已经有了语料库的分词器,可以对句子进行编码。例如,前面章节的简单句子会变成这样

sentences = [

'Today is a sunny day',

'Today is a rainy day',

'Is it sunny today?'

]

sequences = tokenizer.texts_to_sequences(sentences)

print(sequences)

结果为:

[[516, 5229, 147], [516, 6489, 147], [5229, 516]]

如果解码,可以看到停用词已被删除,句子被编码为“today sunny day”、“today rainy day”和“sunny today”。

如果想在代码中解码,可以创建一个新字典,将键和值反转(即词索引中的键值对互换)并进行查找。代码如下:

reverse_word_index = dict(

[(value, key) for (key, value) in tokenizer.word_index.items()])

decoded_review = ' '.join([reverse_word_index.get(i, '?') for i in sequences[0]])

print(decoded_review)

这将输出:

today sunny day

使用IMDb子词数据集

TFDS还包含几个使用子词预处理的IMDb数据集。在这里,你不需要按词分割句子,它们已经按子词进行分割。使用子词是一种在按字母(少量低语义令牌)和按词(大量高语义令牌)之间的折中方法,通常可以非常有效地训练语言分类器。这些数据集还包含用于分割和编码语料库的编码器和解码器。

要访问它们,可以调用tfds.load并传入imdb_reviews/subwords8k或imdb_reviews/subwords32k,例如

(train_data, test_data), info = tfds.load(

'imdb_reviews/subwords8k',

split=(tfds.Split.TRAIN, tfds.Split.TEST),

as_supervised=True,

with_info=True

)

可以通过info对象访问编码器,这将帮助查看词汇量大小

encoder = info.features['text'].encoder

print('Vocabulary size: {}'.format(encoder.vocab_size))

输出8185,因为在此实例中词汇量由8,185个令牌组成。若想查看子词列表,可以使用encoder.subwords属性获取:

print(encoder.subwords)

输出类似以下内容

['the_', ', ', '. ', 'a_', 'and_', 'of_', 'to_', 's_', 'is_', 'br', 'in_', 'I_', 'that_', ...]

这里可以注意到,停用词、标点和语法在语料库中都有,还有像
这样的HTML标签。空格用下划线表示,因此第一个令牌是“the”。

若想编码字符串,可以使用编码器

sample_string = 'Today is a sunny day'

encoded_string = encoder.encode(sample_string)

print('Encoded string is {}'.format(encoded_string))

输出将是令牌列表

Encoded string is [6427, 4869, 9, 4, 2365, 1361, 606]

你的五个词被编码为七个令牌。查看令牌,可以用编码器的subwords属性返回一个数组。它是从零开始的,例如“Today”中的“Tod”编码为6427,是数组中的第6426项:

print(encoder.subwords[6426])

输出:

Tod

若要解码,可以使用编码器的decode方法:

encoded_string = encoder.encode(sample_string)

original_string = encoder.decode(encoded_string)

test_string = encoder.decode([6427, 4869, 9, 4, 2365, 1361, 606])

后面的代码行将产生相同的结果,因为encoded_string尽管名字如此,其实是一个和下一行硬编码的列表相同的令牌列表。

本节总结:
本节主要介绍了如何将文本表达转化为计算机可理解的数字表达形式。 具体来说,就是通过TensorFlow Datasets对文本进行预处理,包括分词、去停用词等步骤,最终将文本转换为数字序列,为后续的自然语言处理任务做好准备;下一篇是这一篇的补充,主要讲述如何从CSV和JSON文件中提取文本用于训练模型。