Sorry, I was wrong, this question is not easy

How to achieve

First, let me briefly introduce how I achieved it at the time.

Friends who are interested in knowing the content and more related learning materials, please like and collect + comment and forward + follow me, there will be a lot of dry goods later. I have some interview questions, architecture, and design materials that can be said to be necessary for programmer interviews!
All the materials are organized into the network disk, welcome to download if necessary! Private message me to reply [111] to get it for free
 

First, four states of the system are defined:

const (
 UNINITIALIZED = iota
 IDLE
 PREPARE
 RUNNING
)

Here, in order to make the code closer to the habit of Go, use iota.

Four states are used, the first state UNINITIALIZEDis not in the Java version, because Java starts the regular cache timestamp thread by default when the system is initialized.

But the Go version is not like this. It has a switch. When the switch is turned on, it will call the StartTimeTickercoroutine to start the cache timestamp, so when it is not initialized, it needs to return the system timestamp directly, so there is an extra UNINITIALIZEDstate here.

Then we need a method that can count QPS. This piece directly copies the implementation of Java. Since it is not the point, but I am afraid that you will not understand it, so I directly paste a little code, and if you don’t want to see it, you can scroll down.

Define the BucketWrap we need:

type statistic struct {
 reads  uint64
 writes uint64
}

func (s *statistic) NewEmptyBucket() interface{} {
 return statistic{
  reads:  0,
  writes: 0,
 }
}

func (s *statistic) ResetBucketTo(bucket *base.BucketWrap, startTime uint64) *base.BucketWrap {
 atomic.StoreUint64(&bucket.BucketStart, startTime)
 bucket.Value.Store(statistic{
  reads:  0,
  writes: 0,
 })
 return bucket
}

Get the current Bucket:

func currentCounter(now uint64) (*statistic, error) {
 if statistics == nil {
  return nil, fmt.Errorf("statistics is nil")
 }

 bk, err := statistics.CurrentBucketOfTime(now, bucketGenerator)
 if err != nil {
  return nil, err
 }
 if bk == nil {
  return nil, fmt.Errorf("current bucket is nil")
 }

 v := bk.Value.Load()
 if v == nil {
  return nil, fmt.Errorf("current bucket value is nil")
 }
 counter, ok := v.(*statistic)
 if !ok {
  return nil, fmt.Errorf("bucket fail to do type assert, expect: *statistic, in fact: %s", reflect.TypeOf(v).Name())
 }

 return counter, nil
}

Get the current QPS:

func currentQps(now uint64) (uint64, uint64) {
 if statistics == nil {
  return 0, 0
 }

 list := statistics.ValuesConditional(now, func(ws uint64) bool {
  return ws <= now && now < ws+uint64(bucketLengthInMs)
 })

 var reads, writes, cnt uint64
 for _, w := range list {
  if w == nil {
   continue
  }

  v := w.Value.Load()
  if v == nil {
   continue
  }

  s, ok := v.(*statistic)
  if !ok {
   continue
  }

  cnt++
  reads += s.reads
  writes += s.writes
 }

 if cnt < 1 {
  return 0, 0
 }

 return reads / cnt, writes / cnt
}

When we have these preparations, let's write the core check logic:

func check() {
 now := CurrentTimeMillsWithTicker(true)
 if now-lastCheck < checkInterval {
  return
 }

 lastCheck = now
 qps, tps := currentQps(now)
 if state == IDLE && qps > hitsUpperBoundary {
  logging.Warn("[time_ticker check] switches to PREPARE for better performance", "reads", qps, "writes", tps)
  state = PREPARE
 } else if state == RUNNING && qps < hitsLowerBoundary {
  logging.Warn("[time_ticker check] switches to IDLE due to not enough load", "reads", qps, "writes", tps)
  state = IDLE
 }
}

Finally, where check is called:

func StartTimeTicker() {
 var err error
 statistics, err = base.NewLeapArray(sampleCount, intervalInMs, bucketGenerator)
 if err != nil {
  logging.Warn("[time_ticker StartTimeTicker] new leap array failed", "error", err.Error())
 }

 atomic.StoreUint64(&nowInMs, uint64(time.Now().UnixNano())/unixTimeUnitOffset)
 state = IDLE
 go func() {
  for {
   check()
   if state == RUNNING {
    now := uint64(time.Now().UnixNano()) / unixTimeUnitOffset
    atomic.StoreUint64(&nowInMs, now)
    counter, err := currentCounter(now)
    if err != nil && counter != nil {
     atomic.AddUint64(&counter.writes, 1)
    }
    time.Sleep(time.Millisecond)
    continue
   }
   if state == IDLE {
    time.Sleep(300 * time.Millisecond)
    continue
   }
   if state == PREPARE {
    now := uint64(time.Now().UnixNano()) / unixTimeUnitOffset
    atomic.StoreUint64(&nowInMs, now)
    state = RUNNING
    continue
   }
  }
 }()
}

Since then, we have implemented (copied) an adaptive cache timestamp algorithm.

have a test

Compile first, boom, an error is reported: import cycle not allowed!

What do you mean? Circular dependency!

Our timestamp acquisition method is in the package util, and then the statistical QPS related implementation we use is in basethe package. The util package depends on the base package, which is easy to understand. On the contrary, the base package also depends on the util package, and the base package mainly uses method CurrentTimeMillisto get the current timestamp. I took a screenshot here, but more than that, it is used in several places:

But when I wrote the code, I deliberately bypassed the circular dependency, that is, calling the method in the base package in the util will not reverse the dependency and come back to form a loop. For this reason, I wrote a separate method:

With the new approach, no dependency loops are formed. But in fact, the compilation still fails, because Go directly prohibits circular dependencies when compiling.

Then I'm curious, how does Java implement it?

this is com.alibaba.csp.sentinel.utilthe package

this is com.alibaba.csp.sentinel.slots.statistic.basethe package

Java also has a circular dependency, but it's ok!

This instantly aroused my interest, what if I let it run to form a dependency loop?

Simply do a test, make two packages, and call each other, for example, the methods of pk1and call each other:pk2code

package org.newboo.pk1;

import org.newboo.pk2.Test2;

public class Test1 {
    public static int code() {
        return Test2.code();
    }

    public static void main(String[] args) {
        System.out.println(code());
    }
}

The compilation can pass, but the stack overflow is reported when running:

Exception in thread "main" java.lang.StackOverflowError
 at org.newboo.pk1.Test1.code(Test1.java:7)
 at org.newboo.pk2.Test2.code(Test2.java:7)
 ...

So it seems that the Go compiler has done a check and enforces that circular dependencies are not allowed.

Speaking of this, in fact, the Java field also has circular dependency verification. For example, Maven does not allow circular dependencies. Compared with my dependency on sentinel-benchmark in the sentinel-core module, an error is reported directly when compiling.

Another example is that SpringBoot2.6.x disables circular dependencies by default. If you want to use it, you have to open it manually.

Only maven is strictly prohibited in Java, and the language level and framework level are basically not eliminated, but Go is forcibly prohibited from being used at the language level.

This reminds me of when I was writing Go code, Go's lock does not allow reentrancy, and I often write deadlock code. There is no problem with Java at all. At that time, I didn't figure out why Go doesn't support lock reentrancy.

Now it seems possible reasons: first, the designer of Go has code cleanliness and wants to force everyone to have a good code style; second, because Go has mandatory detection of circular dependencies, the probability of lock reentry is reduced.

But this is an ideal state after all, and it is often painful to implement.

On the other hand, in Java, it was not mandatory to disable circular dependencies at the beginning, which led to the inevitable writing of circular dependency code later. SpringBoot thinks this is bad, but it cannot be forced, it can only be prohibited by default, but if you really need it, you can also It can still be opened.

But then again, are circular dependencies really "ugly"? I don't think so, the benevolent sees benevolence, and the wise sees wisdom.

How to solve

The problem is that everyone may have different opinions on such a problem, or complain about Go, or criticize Java. This is not the point. The point is that we have to solve the problem under the rules of Go.

How to solve the circular dependency problem in Go? After a little research, there are probably several methods:

method one

Combining two packages into one is the easiest way, but it definitely won't work here. Combining one PR will definitely fail.

Method Two

Extract the common underlying method, both parties rely on this underlying method. For example, here, we extract the underlying method as common, and util and base depend on it at the same time, so that util and base do not depend on each other.

---- util
---- ---- common
---- base
---- ---- common

This method is also the most common and formal method.

But here, it doesn't seem to work well. Because the method of obtaining the timestamp is already very low-level, there is no way to find a method that is shared with the statistical QPS. Anyway, I couldn't figure it out. If there are readers who can do it, welcome to chat with me privately and sincerely ask for advice.

Spent a lot of time and still can't get it done. The feeling at that time was that the car overturned now, this question is not that simple!

method three

This method is difficult to think of, and I only found out after consulting the Go boss in the group when the first two methods couldn't work out.

Take a closer look at the code to get the timestamp:

// Returns the current Unix timestamp in milliseconds.
func CurrentTimeMillis() uint64 {
 return CurrentClock().CurrentTimeMillis()
}

What's here CurrentClock()? In fact, it returns an Clockimplementation of an interface.

type Clock interface {
 Now() time.Time
 Sleep(d time.Duration)
 CurrentTimeMillis() uint64
 CurrentTimeNano() uint64
}

The purpose of the author's writing is to flexibly replace the real implementation when testing

In actual use, RealClock is called to obtain the timestamp we are tuning; MockClock is used for testing.

When was this implementation injected?

func init() {
 realClock := NewRealClock()
 currentClock = new(atomic.Value)
 SetClock(realClock)

 realTickerCreator := NewRealTickerCreator()
 currentTickerCreator = new(atomic.Value)
 SetTickerCreator(realTickerCreator)
}

When the util is initialized, the realClock is injected into it.

So in detail, is it a little bit about the solution to the circular dependency?

Our realClock actually depends on base, but this realClock can be placed outside the util package, leaving only one interface in the util package.

The place where the real realClock is injected cannot be placed in the initialization of the util, but must also be placed outside the util package (such as where Sentinel is initialized), so that the util no longer directly depends on the base.

After such a transformation, the compilation will pass. Of course, this code is just a hint and needs to be refined.

at last

We found that even if we give you the ready-made code, it is more difficult to copy it, which is a bit like the embarrassing situation of "the brain knows it, but the hand can't".

At the same time, each programming language has its own style, which is what we usually say, Go code should be written more "Go", so the language is not just a simple tool, but also has its own way of thinking behind it.

This article actually shares how to solve the circular dependency problem of Go from a case, and some thoughts on comparison with Java, which is more biased towards code engineering.

Guess you like

Origin blog.csdn.net/m0_69424697/article/details/125145206