`
maosheng
  • 浏览: 567822 次
  • 性别: Icon_minigender_1
  • 来自: 北京
社区版块
存档分类
最新评论

Disruptor 介绍

阅读更多
并发的复杂性:

    在计算机科学中,并发的意思是两个或两个以上的任务同时并行的执行,但是也要通过争抢来接入资源。争抢的资源可能是数据库、文件系统、套接字、甚至或者说内存中的一块区域。
    并发的执行代码包括两个方面:互斥性和改变的可见性。互斥性是指线程对资源进行争用状态的改变的管理(这里的争用状态主要是指写的操作要保持互斥性),而改变可见性是指控制何时这种改变对其他线程可见。很明显,如果能消除争用就能够避免互斥性管理——如果有某种算法,能够确保任何给定的资源同一时刻只被一个线程修改,那么互斥性就不是必要的了。读和写操作需要所有改变对其他线程都是可见的,但是只有争用写操作需要保持互斥性。
    在任何并发的环境中,争用写操作是花费最大代价的。为了支持多个并发的线程对同一块资源进行写操作是需要花费复杂而昂贵的代价来进行协调的。最典型的解决这种协调的方法是引入某种锁的策略。

锁的代价:

     锁提供了互斥性并且确保改变对其他线程的可见性以一种命令式的方式发生。锁的代价消耗是难以置信的大——因为它们当遇到争用时需要进行仲裁。这种仲裁是通过一种上下文切换到操作系统的内核,挂起线程等待锁的释放。在这样的上下文切换过程中,也会交还控制权给操作系统——操作系统此时可能同时决定去做其他一些请理性的工作,这样正在执行中的上下文对象将会丢失之前预读的缓存中的数据和指令(这里的缓存指的是CPU缓存)。对现代CPU而言,这将会造成一系列的性能上的坏的影响。可以使用快速的用户模式的锁,但是这也仅当没有争用的时候能够带来真正的益处。

     理想的算法应该是仅仅使用一个单线程来处理所有的对一个资源的写操作,而有多个线程执行读取处理结果的操作。在一个多处理器或多核处理器环境下处理对资源的读操作需要内存栅栏来确保一个线程状态改变对其他处理器上运行的线程可见。

内存栅栏:

     一旦内存数据被推送到缓存,就会有消息协议来确保所有的缓存会对所有的共享数据同步并保持一致。这个使内存数据对CPU核可见的技术被称为内存屏障或内存栅栏。

     内存屏障提供了两个功能。首先,它们通过确保从另一个CPU来看屏障的两边的所有指令都是正确的程序顺序,而保持程序顺序的外部可见性;其次它们可以实现内存数据可见性,确保内存数据会同步到CPU缓存子系统。

     大多数的内存屏障都是复杂的话题。在不同的CPU架构上内存屏障的实现非常不一样。

     内存屏障或内存栅栏,也就是让一个CPU处理单元中的内存状态对其它处理单元可见的一项技术。

     现代的处理器为了提高效率,采用无序的方式执行其指令、在内存和对应的执行单元间加载和存储数据。处理器仅仅需要确保程序逻辑执行出正确的结果而不去关心其执行顺序。这不是单线程程序的特性。但是当线程间彼此共享状态时为了确保数据交换的成功处理,在需要的时点,内存的改变能够按次序发生就是很重要的了。内存栅栏是处理器用来指出代码块在哪里修改内存是需要有序的进行的。它们是硬件排序和线程间保持彼此改变可见性的重要手段。编译器会在适当的位置设置合适的软件栅栏来确保代码按照正确的顺序编译,处理器本身也会使用这样的软件栅栏作为硬件栅栏的一种补充。

    现代的处理器要比同代的内存快得多的多。为了填补这样一个速度差距的鸿沟,CPU使用了复杂的缓存系统——非常快的通过硬件实现的无链哈希表。这些缓存系统通过消息传递协议与其他处理器CPU的缓存系统保持协调一致。另外作为补充,处理器的“存储缓冲”可以将写操作从上述缓冲上卸载下来,在一个写操作将要发生的时候,缓存协调协议通过这样的一个“失效队列”快速的通知失效消息。

    这些对于数据来说意味着,当某个数据值的最后一个版本刚刚被写操作执行之后,将会被存储登记给一个存储缓冲——可能是CPU的某一层缓存、或者是一块内存区域。如果线程想要共享这一数据,那么它需要以一种有序的方式轮流对其他线程可见,这是通过处理器协调消息的协调来实现的。这些及时的协调消息的生成,是内存栅栏来控制的。

    读操作内存栅栏对CPU的加载指令进行排序,通过失效队列来得知当前缓存的改变。这使得读操作内存栅栏对其之前的已排序的写操作有了一个持久化的视界。

    写操作内存栅栏对CPU的存储指令进行排序,通过存储缓冲执行,因此,通过对应的CPU缓存来刷新写输出。写操作内存栅栏提供了一个在其之前的存储操作如何发生的、有序的视界。

    一个完整的内存栅栏即对加载排序也对存储排序,但这只针对执行该栅栏的CPU。

    在Java的内存模型中,对一个volatile类型成员变量的域的读和写,分别实现了读内存栅栏和写内存栅栏。(对volatile类型的成员变量虚拟机不采用优化策略,即不在每个线程中保存其副本,每次读取和修改都将到共享的内存域中进行)。

缓存行:

    现代处理器使用缓存的方式对成功的高性能操作而言具有重要的意义。这种处理器架构在数据搅动和指令存储方面有极大作用,反之,如果缓存出现丢失的话将对系能造成极大的影响。
    我们的硬件在移动内存数据的时候不是以字节和字长为单位的,为了更加有效的工作,缓存被组织成缓存行的形式,每个缓存行为32-256个字节大小,一般是64字节。这是缓存协调协议操作的粒度层级。这意味着如果两个变量在同一个缓存行内,并且它们是被两个不同的线程写入的话,它们将会呈现出相同的写争用问题,就好像它们是一个变量一样!这就是“伪共享”概念。所以为了提高性能,应该确保独立的且并发的写操作、并且写操作的变量不在同一个缓存行内,可以使争用最小化。
   
Disruptor的独特设计:

     Disruptor的目标是尽可能多的在内存中运行。

     处于Disruptor机制中心脏地位的是一个预先分配的有界数据结构形式——环状缓冲。数据通过一个或多个生产者添加到环状缓冲中,并通过一个或多个消费者从其中取出处理。

内存分配:

     环状缓冲(ring buffer)的所有内存空间是在启动的时候预先分配好的。环状缓冲既可以存储一整个数组的指向实体的指针、或者是代表实体本身的数据结构。由于Java语言本身的限制意味着实体是以对象的引用的形式存放在环状缓冲中的。每一个实体一般并不是直接存放的,而是放在一个容器里,而把容器放在缓冲中。这种实体存放空间的预分配的形式终结了支持垃圾回收机制的语言所带来的问题,因为实体会被重复使用并在Disruptor实例的生命周期中一致存在。这些实体的内存空间是在同一时刻分配好的,并且一般来说是在内存中连续的一块地址,因此支持缓存跳跃。

     在一个像Java这样被管理的运行时环境中开发低延迟系统时,垃圾回收可能会是个问题。分配的内存越多,垃圾收集器的负担就越大。当对象的生命周期都极短或者对象永不销毁的情况下,垃圾收集器可以达到最佳性能(实际上是最小的负担)。环状缓冲中的实体内存是预先分配好的,意味着它在垃圾回收器工作的时候是永不销毁的,所以只带来很少的负担。

    由于基于队列的系统在高负载时会导致执行率降低、并且导致分配的对象释放其所在空间上的延迟,所以一代又一代的垃圾收集器都在这一点上进行不断优化。这有两层意思:第一,对象不得不在每一代之间进行复制,这导致了不定的延迟。第二,这些对象可能会从旧代中收集,这可能会是更加消耗性的操作,可能增加“世界停止”一般的暂停,这发生在零碎的内存空间被重新压实的时候。在大内存堆中这会导致每GB数秒的暂停。

批量效应:

     当消费者等待环状缓冲中最新可用的游标序列号时会有一定几率发生一个在队列中不会发生的有趣的现象:如果消费者发现与它上次检查的时候相比,环状缓冲的游标已经向前走了许多步的话,它可以直接处理到那个最新的序列号而不必纠缠于并发机制。这样的结果是本来落后的生产者会重新赢得与之前突然爆发的生产者的赛跑比赛,重新平衡了系统。这种批量效应增加了处理吞吐量并减少和平稳了延迟。根据我们的观察,在内存子系统饱和之前,不管负载多大,这种效应的延迟始终接近一个时间常量,对应的变化曲线是线性的并遵循利特尔法则,这与我们使用队列时在负载不断增加时延迟呈指数级增长得到的J形曲线是截然不同的。

Disruptor的类结构图:





生产者通过ProducerBarrier使用序列号声明实体,在声明好的实体中写入改变,然后通过ProducerBarrier将实体提交回来并使其可以被消费者使用。而消费者仅仅需要提供一个BatchHandler的实现即可,该实现负责接收当一个新的实体可用时的回调请求。

Disruptor模式的核心——RingBuffer,可以提供存储使得数据的交换在不发生争用的情况下进行。经由生产者、消费者与RingBuffer的交互中分离出了传统并发所带来的问题。ProducerBarrier负责管理所有在环状缓冲中声明序列位置的并发问题,并跟踪各个消费者以确保这个环不会缠绕。(在RingBuffer这个环状的跑道上,最快的生产者超过了最慢的消费者,即为环的缠绕。)ConsumerBarrier用来提醒消费者是否有新的实体可用,这样消费者便可以构造图状的依赖关系用来表示一个处理关系中多个阶段。


Disruptor 是什么:

Disruptor 是一个 Java 的并发编程框架,大大的简化了并发程序开发的难度,在性能上也比 Java 本身提供的一些并发包要好

Disruptor 是一个高性能异步处理框架,它实现了观察者模式

Disruptor :它是无锁的、CPU友好;它不会清除缓存中的数据,只会覆盖,降低了垃圾回收机制启动的频率。


Disruptor 为什么快:

1. 不使用锁,通过内存屏障和原子性的CAS操作替换锁

2. 缓存基于数组而不是链表,用位运算替代求模。缓存的长度总是2的n次方,这样可以用位运算 i & (length - 1) 替代 i % length

3. 去除伪共享,CPU的缓存一般是以缓存行为最小单位的,对应主存的一块相应大小的单元;当前的缓存行大小一般是64字节,每个缓存行一次只能被一个CPU核访问,如果一个缓存行被多个CPU核访问,就会造成竞争,导致某个核必须等其他核处理完了才能继续处理,响应性能。去除伪共享就是确保CPU核访问某个缓存行时不会出现争用

4.预分配缓存对象,通过更新缓存里对象的属性而不是删除对象来减少垃圾回收


核心类和接口:

EventHandler:用户提供具体的实现,在里面实现事件的处理逻辑。由用户实现并且代表了Disruptor中的一个消费者的接口。


Sequence:代表事件序号或一个指向缓存某个位置的序号。Disruptor使用Sequence来表示一个特殊组件处理的序号。和Disruptor一样,每个消费者(EventProcessor)都维持着一个Sequence。大部分的并发代码依赖这些Sequence值的运转,因此Sequence支持多种当前AtomicLong类的特性。事实上,这两者之间唯一的区别是Sequence包含额外的功能来阻止Sequence和其他值之间的共享。


WaitStrategy:功能包括:当没有可消费的事件时,根据特定的实现进行等待,有可消费事件时返回可事件序号;有新事件发布时通知等待的 SequenceBarrier。它决定了一个消费者将如何等待生产者将Event置入Disruptor。


Sequencer:生产者用于访问缓存的控制器,它持有消费者序号的引用;新事件发布后通过WaitStrategy 通知正在等待的SequenceBarrier。这是Disruptor真正的核心。实现了这个接口的两种生产者(单生产者和多生产者)均实现了所有的并发算法,为了在生产者和消费者之间进行准确快速的数据传递。


SequenceBarrier:消费者关卡。消费者用于访问缓存的控制器,每个访问控制器还持有前置访问控制器的引用,用于维持正确的事件处理顺序;通过WaitStrategy获取可消费事件序号。由Sequencer生成,并且包含了已经发布的Sequence的引用,这些的Sequence源于Sequencer和一些独立的消费者的Sequence。它包含了决定是否有供消费者来消费的Event的逻辑。


EventProcessor:事件处理器,是可执行单元,运行在指定的Executor里;它会不断地通过SequenceBarrier获取可消费事件,当有可消费事件时调用用户提供的 EventHandler实现处理事件。主要的事件循环,用于处理Disruptor中的Event,并且拥有消费者的Sequence。它有一个实现类是BatchEventProcessor,包含了event loop有效的实现,并且将回调到一个EventHandler接口的实现对象。


EventTranslator:事件转换器,由于Disruptor只会覆盖缓存,需要通过此接口的实现来更新缓存里的事件来覆盖旧事件。


RingBuffer:基于数组的缓存实现,它内部持有对Executor、WaitStrategy、生产者和消费者访问控制器的引用。


Disruptor:提供了对 RingBuffer 的封装,并提供了一些DSL风格的方法,方便使用。


Disruptor 模型:





其中的RingBuffer被组织成环形队列,但它与我们在常常使用的队列又不一样,这个队列大小固定,且每个元素槽都以一个整数进行编号,RingBuffer中只有一个游标维护着一个指向下一个可用位置的序号,生产者每次向RingBuffer中写入一个元素时都需要向RingBuffer申请一个可写入的序列号,如果此时RingBuffer中有可用节点,RingBuffer就向生产者返回这个可用节点的序号,如果没有,那么就等待。同样消费者消费的元素序号也必须是生产者已经写入了的元素序号。

RingBuffer:

      首先,因为它是数组,所以要比链表快,而且有一个容易预测的访问模式。(数组内元素的内存地址的连续性存储的)。这是对CPU缓存友好的—也就是说,在硬件级别,数组中的元素是会被预加载的,因此在ringbuffer当中,cpu无需时不时去主存加载数组中的下一个元素。(因为只要一个元素被加载到缓存行,其他相邻的几个元素也会被加载进同一个缓存行)

      其次,你可以为数组预先分配内存,使得数组对象一直存在(除非程序终止)。这就意味着不需要花大量的时间用于垃圾回收。此外,不像链表那样,需要为每一个添加到其上面的对象创造节点对象—对应的,当删除节点时,需要执行相应的内存清理操作。

      CPU和主内存之间有好几层缓存,因为即使直接访问主内存也是非常慢的。如果你正在多次对一块数据做相同的运算,那么在执行运算的时候把它加载到离CPU很近的地方就有意义了

     越靠近CPU的缓存越快也越小。所以L1缓存很小但很快(L1表示一级缓存),并且紧靠着在使用它的CPU内核。L2大一些,也慢一些,并且仍然只能被一个单独的 CPU 核使用。L3在现代多核机器中更普遍,仍然更大,更慢,并且被单个插槽上的所有 CPU 核共享。最后,你拥有一块主存,由全部插槽上的所有 CPU 核共享。

     当CPU执行运算的时候,它先去L1查找所需的数据,再去L2,然后是L3,最后如果这些缓存中都没有,所需的数据就要去主内存拿。走得越远,运算耗费的时间就越长。所以如果你在做一些很频繁的事,你要确保数据在L1缓存中。





缓存行:

     数据在缓存中不是以独立的项来存储的,如不是一个单独的变量,也不是一个单独的指针。缓存是由缓存行组成的,通常是64字节(译注:这篇文章发表时常用处理器的缓存行是64字节的,比较旧的处理器缓存行是32字节),并且它有效地引用主内存中的一块地址。一个Java的long类型是8字节,因此在一个缓存行中可以存8个long类型的变量。




     非常奇妙的是如果你访问一个long数组,当数组中的一个值被加载到缓存中,它会额外加载另外7个。因此你能非常快地遍历这个数组。事实上,你可以非常快速的遍历在连续的内存块中分配的任意数据结构。它解释了我们的ring buffer使用数组的原因。

     因此如果你数据结构中的项在内存中不是彼此相邻的(链表),你将得不到免费缓存加载所带来的优势。并且在这些数据结构中的每一个项都可能会出现缓存未命中。

     不过,所有这种免费加载有一个弊端。设想你的long类型的数据不是数组的一部分。设想它只是一个单独的变量。让我们称它为head,这么称呼它其实没有什么原因。然后再设想在你的类中有另一个变量紧挨着它。让我们直接称它为tail。现在,当你加载head到缓存的时候,你也免费加载了tail。




     听想来不错。直到你意识到tail正在被你的生产者写入,而head正在被你的消费者写入。这两个变量实际上并不是密切相关的,而事实上却要被两个不同内核中运行的线程所使用。





设想你的消费者更新了head的值。缓存中的值和内存中的值都被更新了,而其他所有存储head的缓存行都会都会失效,因为其它缓存中head不是最新值了。请记住我们必须以整个缓存行作为单位来处理(这是CPU的实现所规定的),不能只把head标记为无效。




现在如果一些正在其他内核中运行的进程只是想读tail的值,整个缓存行需要从主内存重新读取。那么一个和你的消费者无关的线程读一个和head无关的值,它被缓存未命中给拖慢了。

当然如果两个独立的线程同时写两个不同的值会更糟。因为每次线程对缓存行进行写操作时,每个内核都要把另一个内核上的缓存块无效掉并重新读取里面的数据。你基本上是遇到两个线程之间的写冲突了,尽管它们写入的是不同的变量。

这叫作“伪共享”,因为每次你访问head你也会得到tail,而且每次你访问tail,你也会得到head。这一切都在后台发生,并且没有任何编译警告会告诉你,你正在写一个并发访问效率很低的代码。

你会看到Disruptor通过缓存行填充消除这个问题,至少对于缓存行大小是64字节或更少的处理器架构来说是这样的(有可能处理器的缓存行是128字节,那么使用64字节填充还是会存在伪共享问题),通过增加补全来确保ring buffer的序列号不会和其他东西同时存在于一个缓存行中。

因此没有伪共享,就没有和其它任何变量的意外冲突,没有不必要的缓存未命中。

在你的Entry类中也值得这样做,如果你有不同的消费者往不同的字段写入,你需要确保各个字段间不会出现伪共享。







  • 大小: 13.2 KB
  • 大小: 58.3 KB
  • 大小: 27.9 KB
  • 大小: 22.4 KB
  • 大小: 25.9 KB
  • 大小: 34.8 KB
  • 大小: 35.6 KB
分享到:
评论

相关推荐

    Disruptor并发框架中文参考文档

    下面将详细介绍Disruptor的工作原理及其关键技术点。 #### 二、剖析Disruptor高效的原因 ##### 1.1 锁的缺点 锁是一种常见的同步机制,用于确保在多线程环境中对共享资源的独占访问。然而,锁的存在也带来了一...

    Disruptor_doc_ZH_CN:Disruptor中文参考

    并发框架Disruptor介绍Martin Fowler在自己网站上写了一篇LMAX架构的文章,在文章中他介绍了LMAX是一种新型零售金融交易平台,它能够以很低的延迟产生大量交易。这个系统是建立在JVM平台上,其核心是一个业务逻辑...

    Disruptor demo

    下面将详细介绍这些概念和Disruptor的核心机制: 1. **环形缓冲区(Ring Buffer)**:Disruptor的核心是环形缓冲区,这是一个固定大小的数组,用于存储待处理的事件。不同于传统的队列,环形缓冲区的读写指针在环形...

    Disruptor示例

    Martin Fowler在自己网站上写了一篇LMAX架构的文章,在文章中他介绍了LMAX是一种新型零售金融交易平台,它能够以很低的延迟产生大量交易。这个系统是建立在JVM平台上,其核心是一个业务逻辑处理器,它能够在一个线程...

    高并发框架Disruptor代码

    本文将详细介绍Disruptor的核心原理、设计模式及其在实际中的应用。 Disruptor的诞生源于LMAX对金融交易系统极端性能的需求。传统并发模型如锁和队列在高并发环境下可能导致CPU缓存失效,进而引发性能瓶颈。...

    share-disruptor.zip

    下面将详细介绍Disruptor的关键概念和技术特点: 1. **环形缓冲区(Ring Buffer)**:Disruptor的核心是环形缓冲区,它是一个固定大小的数组,用于存储待处理的事件。这个设计避免了在内存中频繁分配和释放空间,...

    基于Spring Boot和LMAX Disruptor的高性能并发框架.zip

    介绍无锁并行计算框架Disruptor,并进行压力测试与JDK的BlockingQueue进行性能对比。 2. 无锁并行计算框架核心 学习无锁并行计算框架的基础使用与API。 介绍内部各种组件的原理和运行机制。 3. 无锁并行计算...

    disruptor concurency pattern in c++.zip

    本篇文章将详细介绍Disruptor模式,并探讨如何在C++中实现和应用这一模式。 Disruptor模式的核心思想是消除线程间的共享数据,通过一个环形缓冲区(Ring Buffer)来传递消息,从而避免了锁和条件变量带来的性能开销...

    async-framework:基于Disruptor的异步并行框架

    async-framework是基于google开源框架Disruptor开发的一个异步流程处理框架,关于Disruptor的介绍请参考 async-framework提供了流程和队列的概念,流程 Flow 代表步骤,队列 Queue 代表处理节点,队列由Disruptor...

    disruptor-starter:干扰器的使用示例

    本文将围绕"disruptor-starter"项目,详细介绍Disruptor的使用及其在实际开发中的应用。 首先,我们来看"disruptor-starter"项目的标题——"干扰器的使用示例"。这个项目是为初学者提供了一个学习和理解Disruptor...

    Spring Boot 项目使用 Disruptor 做内部消息队列.zip

    计算机技术、IT咨询、人工智能AI理论介绍,学习参考资料计算机技术、IT咨询、人工智能AI理论介绍,学习参考资料计算机技术、IT咨询、人工智能AI理论介绍,学习参考资料计算机技术、IT咨询、人工智能AI理论介绍,学习...

    基于Java的开源游戏服务器框架实现,使用了Netty、ProtoBuf、Disruptor等

    【作品名称】:基于Java的开源游戏服务器框架实现,使用了Netty、ProtoBuf、Disruptor等 【适用人群】:适用于希望学习不同...【项目介绍】:基于Java的开源游戏服务器框架实现,使用了Netty、ProtoBuf、Disruptor等

    基于JavaDisruptor的并发编程深度学习项目.zip

    基于JavaDisruptor的并发编程... 介绍无锁并行计算框架Disruptor。 进行压力测试与JDK的BlockingQueue进行性能对比。 无锁并行计算框架核心 学习Disruptor的基础使用与API。 介绍内部各种组件的原理和运行机制。

    disrupter的使用简单demo

    下面将详细介绍Disruptor的使用及其在并发编程中的作用。 首先,我们要理解Disruptor的核心组件——环形缓冲区。环形缓冲区是一种特殊的数组结构,它的大小是固定的,并且以循环的方式分配和释放空间。这种设计减少...

    一个基于Java的开源游戏服务器框架实现,使用了Netty、ProtoBuf、Disruptor等.zip

    项目文档:详细的项目文档,介绍了项目的背景、功能、架构以及实现细节,帮助你更好地理解项目。 操作手册与使用说明:针对每个游戏项目,都准备了详细的操作手册和使用说明,手把手教你如何运行和测试项目。 学习...

    大学计算机基础PPt

    本课件详细介绍了PowerPoint的基础知识和操作步骤,适合大学计算机课程的教学使用。 1. **PowerPoint 2000概述**:PowerPoint是一款强大的演示文稿制作软件,主要用途包括制作演讲稿、报告、教学材料等。启动...

    flow-disruptor:确定性的按流网络条件故障模拟器

    介绍flow-disruptor是确定性的每流网络状况模拟器。 为了稍微简化一下描述,每个流意味着针对每个TCP连接(实际上是连接的每个流)分别维护和模拟网络条件。 确定性意味着我们将尽可能多的网络条件(例如RTT,吞吐量...

    电话证券委托交易系统

    第2章 系统总体介绍 3 1. 系统组成 3 2. 系统功能 4 3. 系统性能 5 3.1.系统的先进性和开放性 5 3.2.系统的可靠性和安全性 5 3.3.系统的可扩展性及升级能力 6 第3章 硬件系统 7 1.TELESERVER系统 ...

    java web标签大全

    下面将详细介绍这些技术标签的相关知识点。 1. JSP(JavaServer Pages):JSP是一种基于Java的服务器端脚本语言,用于创建动态网页。JSP标签主要分为内置对象标签、指令标签和动作标签。内置对象如request、...

    敏捷开发书籍相关源代码 java版本

    Martin(Bob大叔)编著的经典书籍,它深入介绍了敏捷开发理念,并提供了大量Java语言实现的实例。这本书旨在帮助开发者理解和应用敏捷开发原则,通过模式和实践来提高软件开发效率和质量。 1. **敏捷开发**:敏捷...

Global site tag (gtag.js) - Google Analytics