并发安全的单例模式(PHP&Go)

一、Go代码实现

方案一(采用Double-Checked Locking模式):

package normal

import (
    "fmt"
    "sync"
)

var lock = &sync.Mutex{}

type single struct {
}

var singleInstance *single

func getInstance() *single {
    if singleInstance == nil {
        lock.Lock()
        defer lock.Unlock()
        if singleInstance == nil {
            fmt.Println("Creating single instance now.")
            singleInstance = &single{}
        } else {
            fmt.Println("Single instance already created.")
        }
    } else {
        fmt.Println("Single instance already created.")
    }

    return singleInstance
}

测试代码

package normal

import (
    "sync"
    "testing"
    "time"
)

func TestGetInstance(t *testing.T) {
    var wg sync.WaitGroup

    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            getInstance()
        }()
    }

    wg.Wait()
}

1、说明

在这段代码中,lock 用于确保只有一个线程可以进入临界区,以防止多个线程同时创建 single 实例。你可能会注意到在 getInstance() 函数中,有两次检查 singleInstance 是否为 nil,一次在没有获取锁之前,一次在获取锁之后。这是为了提高性能并减少竞态条件(race condition)的概率。

2、为什么有两次判空检查?

  1. 第一次检查 (without lock): 在没有获取锁之前,首先检查 singleInstance 是否为 nil。如果 singleInstance 不为 nil,那么意味着已经有一个实例被创建,此时不需要获取锁,直接返回现有的实例。这可以提高性能,因为在大多数情况下,只有第一次需要获取锁来创建实例。
  2. 第二次检查 (with lock): 如果 singleInstance 为 nil,表示没有实例被创建,此时获取锁以进入临界区。然后再次检查 singleInstance 是否为 nil,因为在获取锁之前可能有其他线程进入了临界区。如果在获取锁之前已经有一个线程创建了实例,那么当前线程就不再创建实例,而是返回已创建的实例。

这种双重检查的方式可以减少锁的争用,提高性能,因为大多数情况下只有第一个线程需要获取锁,而其他线程可以避免等待并直接返回现有实例。这被称为”Double-Checked Locking”模式,尽管在一些情况下可能存在微妙的并发问题,但通常情况下可以正常工作。要确保正确性,必须在 singleInstance 的创建和赋值操作上使用原子操作,以避免竞态条件。

方案二(采用sync.Once):

package once

import (
    "fmt"
    "sync"
)

type single struct {
    // Add any fields you want for your singleton here
}

var (
    singleInstance *single
    once           sync.Once
)

func getInstance() *single {
    once.Do(func() {
        fmt.Println("Creating single instance now.")
        singleInstance = &single{}
    })
    fmt.Println("Returning single instance.")
    return singleInstance
}

测试代码

package once

import (
    "fmt"
    "sync"
    "testing"
)

func TestGetInstance(t *testing.T) {
    instance1 := getInstance()
    instance2 := getInstance()

    fmt.Printf("Are instance1 and instance2 the same? %v\n", instance1 == instance2)

    var wg sync.WaitGroup

    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            getInstance()
        }()
    }

    wg.Wait()
}

1、说明

使用sync.Once来实现单例模式可以更加简单和安全,因为sync.Once确保函数只会被执行一次,而无需手动处理锁和双重检查。Once结构体内部其实是一个uint32类型的变量done,用来标记是否已执行(通过atomic包来加载保证原子性),外加一个Mutex类型的互斥锁变量m。

2、两种方案的差异

  1. sync.Once 的实现:
  • sync.Once 内部使用了一个布尔标志来确保其中的函数只会被执行一次。
  • 当第一个调用 Do 方法时,会执行传入的函数并设置布尔标志为已执行。
  • 后续的调用 Do 方法会检查布尔标志,如果已经执行过了,它们不会再次执行传入的函数,直接返回。
  • 这是一个非常简洁和安全的方式来保证某个函数只执行一次,而不需要手动处理锁或条件等待。
  1. Double-Checked Locking 的方式:
  • Double-Checked Locking 是一种传统的并发编程模式,通常使用互斥锁(如 sync.Mutex)来实现。
  • 在 Double-Checked Locking 中,两次检查 singleInstance == nil 分别在获取锁之前和之后进行。
  • 第一次检查是为了尽量避免不必要的锁竞争。如果 singleInstance 不为 nil,那么不需要获取锁,直接返回已有的实例。
  • 第二次检查在获取锁后,以确保只有一个线程真正创建实例,避免竞态条件。

关于它们的区别:

  • sync.Once 是一个 Go 语言标准库提供的原语,使用它更简单和安全,因为它封装了一切,不需要手动管理锁。
  • Double-Checked Locking 是一种模式,它需要手动管理锁和双重检查。虽然它可以工作,但在某些情况下可能会有微妙的并发问题,需要谨慎设计,使用原子操作来确保正确性。

二、PHP代码实现

<?php

class Singleton
{

    private static $instances = [];

    public static function getInstance(): Singleton
    {
        $cls = static::class;
        if (!isset(self::$instances[$cls])) {
            self::$instances[$cls] = new static();
        }

        return self::$instances[$cls];
    }
}

// 测试代码
function clientCode()
{
    $s1 = Singleton::getInstance();
    $s2 = Singleton::getInstance();

    if ($s1 === $s2) {
        echo "Singleton works, both variables contain the same instance.";
    } else {
        echo "Singleton failed, variables contain different instances.";
    }
}

clientCode();

1、说明

在上述 PHP 代码中,使用了静态属性 self::$instances 来存储单例实例,同时在 getInstance() 方法中进行了创建和返回操作。

2、这种方式可以保证并发安全吗?

这种方式可以保证并发安全性,因为 PHP 在请求处理之间是单线程的(除非你在 Web 服务器中使用多进程或多线程配置),所以不会出现多线程并发的情况。

在 PHP 中,你通常不需要太多担心多线程并发问题,因为 PHP 的执行模型天生适用于 Web 请求和响应。每个请求都是相对独立的,没有共享内存或线程池等概念,因此并发安全通常不是一个问题。

所以,这个单例实现是适用的,但需要注意以下几点:

  1. 单例模式确保一个类只有一个实例,并提供了一个全局访问点。在 Web 应用中,这通常与请求的生命周期相对应,而不是全局应用生命周期。

  2. 在多进程或多线程环境下,如果每个进程或线程都有自己的类加载器和内存空间,那么在每个进程或线程中都会有一个独立的单例实例。这可能会导致单例不再全局唯一。

  3. 如果你的 PHP 应用是一个长时间运行的守护进程,而不是每次请求都新建一个 PHP 进程,那么你需要更小心地处理单例模式,确保它在进程生命周期内是唯一的。

总的来说,这个代码示例在一般的 Web 请求处理环境下是安全的。

3、这里使用静态属性和方法的意义?

在这个 PHP 单例模式示例中,使用静态属性和方法是有特定的意义的,它们用于以下目的:

  1. 静态属性 $instances:这个数组用于存储不同的单例实例。使用静态属性的优势在于,它在类的所有实例之间是共享的,确保了各个实例之间能够共享相同的单例实例。

  2. 静态方法 getInstance():这个方法是一个工厂方法,用于获取单例实例。它是静态的,因此可以在不创建类的实例的情况下调用。它通过检查 $instances 中是否已经有了特定类的实例来决定是否需要创建新实例或返回现有实例。

使用静态属性和方法的优势在于:

  • 无需创建类的实例即可访问单例实例,这样可以确保只有一个实例被创建,因为构造函数是私有的,不允许通过 new 运算符创建实例。

  • 静态属性 $instances 存储了不同类的实例,因此允许在多个类和其子类之间共享相同的实例。这种实现方式可以支持单例的子类化(sub-classing),即允许为不同的子类创建单例实例。

总之,静态属性和方法在这里的使用是为了创建可共享、全局唯一的单例实例,并且能够支持子类化。这是一种常见的单例模式实现方式。

4、PHP是单进程还是多进程?

  • 你可以这样理解:对应一个客户的一个页面请求处理的php,是单线程处理的, 这样就可以自上而下执行代码中的业务逻辑了
  • 每个PHP文件的执行是单线程的,但是,服务器(apache/nigix/php-fpm)是多线程的。每次对某个PHP文件的访问服务器都会创建一个新的进程/线程,用来执行对应的PHP文件。
  • 其实一般写 PHP 程序认为是单线程的就可以了。多个请求之间相互的关系就是,有些时候读写数据库,文件,session等会加锁,会导致后面的请求挂起等待前面的请求执行完才继续。

也就是说对于一个请求来说PHP是单线程的,但是多个请求间是并发的。

我们常说的多进程是针对某个请求而言的。比如当前有一个 request,此时会有一个进程。在 PHP 的模式下,就只能该进程去处理任务 (不考虑其他的扩展类库使用),然而对于 Go、Java 它可以在此基础上开启新的线程或者协程。

一家工程十条流水线 (一个 service,开启 10 个进程)。PHP 是一条流水线只有一个工人 (一个线程),而 Java、Go 可以在每一条流水线上分 n 个工人 (多个线程)。

本作品采用《CC 协议》,转载必须注明作者和本文链接
讨论数量: 3
zds

我记得以前在哪里看到的有人说。永远不要把类的单例交给类自己去维护。那样是屎山代码的开始。php是这样,go语言我刚开始学不怎么了解

5个月前 评论
落尘埃 (楼主) 5个月前
zds (作者) 4个月前

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!